diff --git a/main.py b/main.py index 17877fc7..f05dbab7 100644 --- a/main.py +++ b/main.py @@ -111,25 +111,51 @@ async def lifespan(app: FastAPI): # 1. Infrastructure Setup # ========================================================================= + # Initialize the secrets manager and log the master account in BEFORE any Gateway + # client exists: hummingbot's GatewayHttpClient decrypts its mTLS client key with + # Security.secrets_manager.password, which stays None until a login. Logging in + # here (instead of lazily on first connector creation) means the status monitor's + # very first ping can never hit an uninitialized Security. + secrets_manager = ETHKeyFileSecretManger(password=settings.security.config_password) + if not BackendAPISecurity.login_account(account_name="master_account", secrets_manager=secrets_manager): + raise RuntimeError( + "CONFIG_PASSWORD does not match the stored password verification file at " + f"bots/{settings.app.password_verification_path}" + ) + # Initialize GatewayHttpClient singleton + from utils.gateway_certs import certs_present, sync_client_certs_to_root parsed_gateway_url = urlparse(settings.gateway.url) gateway_use_ssl = parsed_gateway_url.scheme == "https" if gateway_use_ssl: # SEC-048: the in-process GatewayHttpClient reads its client certs only from # root_path()/certs. Mirror the shared cert set there if the Gateway was already # started in a previous run (no-op when certs haven't been generated yet). - from utils.gateway_certs import sync_client_certs_to_root sync_client_certs_to_root() gateway_config = GatewayConfigMap( gateway_api_host=parsed_gateway_url.hostname or "localhost", gateway_api_port=str(parsed_gateway_url.port or 15888), gateway_use_ssl=gateway_use_ssl ) - GatewayHttpClient.get_instance(gateway_config) + gateway_client = GatewayHttpClient.get_instance(gateway_config) + # Start the Gateway status monitor so Gateway's network connectors (e.g. + # 'solana-mainnet-beta', and any newly added chain like 'ethereum-unichain') are + # discovered from /config/chains and registered in AllConnectorSettings. Without + # it, a Gateway network only lands in AllConnectorSettings lazily, when a connector + # for it is first constructed; the monitor makes new chains enumerable without + # first deploying a bot on them, and picks them up when Gateway comes online later. + # On a fresh install the shared mTLS certs don't exist until the Gateway is first + # started, and every 2s ping would fail loudly building the SSL context — defer; + # GatewayService.start() starts the monitor once the certs are generated. + if not gateway_use_ssl or certs_present(): + gateway_client.start_monitor() + else: + logging.info( + "Gateway mTLS certs not generated yet; status monitor deferred until the Gateway is started" + ) logging.info(f"Initialized GatewayHttpClient with URL: {settings.gateway.url}") - # Initialize secrets manager and database - secrets_manager = ETHKeyFileSecretManger(password=settings.security.config_password) + # Initialize database db_manager = AsyncDatabaseManager(settings.database.url) await db_manager.create_tables() logging.info("Database initialized") @@ -309,6 +335,7 @@ async def lifespan(app: FastAPI): await executor_service.stop() market_data_service.stop() await connector_service.stop_all() + GatewayHttpClient.get_instance().stop_monitor() docker_service.cleanup() await db_manager.close() diff --git a/routers/gateway.py b/routers/gateway.py index b7a75041..ec5f4419 100644 --- a/routers/gateway.py +++ b/routers/gateway.py @@ -4,14 +4,9 @@ from fastapi import APIRouter, Depends, HTTPException, Query from deps import get_accounts_service, get_gateway_service -from models import ( - AddPoolRequest, - AddTokenRequest, - GatewayConfig, - GatewayStatus, - UpdateApiKeysRequest, -) +from models import AddPoolRequest, AddTokenRequest, GatewayConfig, GatewayStatus, UpdateApiKeysRequest from services.accounts_service import AccountsService +from services.gateway_client import GatewayError, check_gateway_error from services.gateway_service import GatewayService router = APIRouter(tags=["Gateway"], prefix="/gateway") @@ -171,11 +166,13 @@ async def list_connectors(accounts_service: AccountsService = Depends(get_accoun if not await accounts_service.gateway_client.ping(): raise HTTPException(status_code=503, detail="Gateway service is not available") - result = await accounts_service.gateway_client._request("GET", "config/connectors") + result = check_gateway_error(await accounts_service.gateway_client.get_connectors()) return normalize_gateway_response(result) except HTTPException: raise + except GatewayError as e: + raise HTTPException(status_code=e.status, detail=f"Gateway error listing connectors: {e}") except Exception as e: raise HTTPException(status_code=500, detail=f"Error listing connectors: {str(e)}") @@ -195,11 +192,13 @@ async def get_connector_config( if not await accounts_service.gateway_client.ping(): raise HTTPException(status_code=503, detail="Gateway service is not available") - result = await accounts_service.gateway_client.get_config(connector_name) + result = check_gateway_error(await accounts_service.gateway_client.get_config(connector_name)) return normalize_gateway_response(result) except HTTPException: raise + except GatewayError as e: + raise HTTPException(status_code=e.status, detail=f"Gateway error getting connector config: {e}") except Exception as e: raise HTTPException(status_code=500, detail=f"Error getting connector config: {str(e)}") @@ -227,7 +226,9 @@ async def update_connector_config( for path, value in config_updates.items(): # Convert snake_case to camelCase if needed camel_path = snake_to_camel(path) if '_' in path else path - result = await accounts_service.gateway_client.update_config(connector_name, camel_path, value) + result = check_gateway_error( + await accounts_service.gateway_client.update_config(connector_name, camel_path, value) + ) results.append(result) return { @@ -240,6 +241,8 @@ async def update_connector_config( except HTTPException: raise + except GatewayError as e: + raise HTTPException(status_code=e.status, detail=f"Gateway error updating connector config: {e}") except Exception as e: raise HTTPException(status_code=500, detail=f"Error updating connector config: {str(e)}") @@ -266,11 +269,12 @@ async def get_api_keys(accounts_service: AccountsService = Depends(get_accounts_ if not await accounts_service.gateway_client.ping(): raise HTTPException(status_code=503, detail="Gateway service is not available") - result = await accounts_service.gateway_client.get_api_keys() - return result + return check_gateway_error(await accounts_service.gateway_client.get_api_keys()) except HTTPException: raise + except GatewayError as e: + raise HTTPException(status_code=e.status, detail=f"Gateway error getting API keys: {e}") except Exception as e: raise HTTPException(status_code=500, detail=f"Error getting API keys: {str(e)}") @@ -336,11 +340,12 @@ async def list_chains(accounts_service: AccountsService = Depends(get_accounts_s if not await accounts_service.gateway_client.ping(): raise HTTPException(status_code=503, detail="Gateway service is not available") - result = await accounts_service.gateway_client.get_chains() - return result + return check_gateway_error(await accounts_service.gateway_client.get_chains()) except HTTPException: raise + except GatewayError as e: + raise HTTPException(status_code=e.status, detail=f"Gateway error listing chains: {e}") except Exception as e: raise HTTPException(status_code=500, detail=f"Error listing chains: {str(e)}") @@ -368,7 +373,9 @@ async def list_pools_legacy( # This is a simple mapping - in production, you'd want to look this up chain = "solana" if connector_name in ["raydium", "meteora", "orca", "pancakeswap-sol"] else "ethereum" - pools = await accounts_service.gateway_client.get_pools(chain, network, connector=connector_name) + pools = check_gateway_error( + await accounts_service.gateway_client.get_pools(chain, network, connector=connector_name) + ) if not pools: return [] @@ -379,6 +386,8 @@ async def list_pools_legacy( except HTTPException: raise + except GatewayError as e: + raise HTTPException(status_code=e.status, detail=f"Gateway error getting pools: {e}") except Exception as e: raise HTTPException(status_code=500, detail=f"Error getting pools: {str(e)}") @@ -399,7 +408,7 @@ async def list_networks(accounts_service: AccountsService = Depends(get_accounts if not await accounts_service.gateway_client.ping(): raise HTTPException(status_code=503, detail="Gateway service is not available") - chains_result = await accounts_service.gateway_client.get_chains() + chains_result = check_gateway_error(await accounts_service.gateway_client.get_chains()) # Flatten chain-network combinations into network IDs networks = [] @@ -422,6 +431,8 @@ async def list_networks(accounts_service: AccountsService = Depends(get_accounts except HTTPException: raise + except GatewayError as e: + raise HTTPException(status_code=e.status, detail=f"Gateway error listing networks: {e}") except Exception as e: raise HTTPException(status_code=500, detail=f"Error listing networks: {str(e)}") @@ -443,11 +454,13 @@ async def get_network_config( if not await accounts_service.gateway_client.ping(): raise HTTPException(status_code=503, detail="Gateway service is not available") - result = await accounts_service.gateway_client.get_config(network_id) + result = check_gateway_error(await accounts_service.gateway_client.get_config(network_id)) return normalize_gateway_response(result) except HTTPException: raise + except GatewayError as e: + raise HTTPException(status_code=e.status, detail=f"Gateway error getting network config: {e}") except Exception as e: raise HTTPException(status_code=500, detail=f"Error getting network config: {str(e)}") @@ -477,7 +490,9 @@ async def update_network_config( for path, value in config_updates.items(): # Convert snake_case to camelCase if needed camel_path = snake_to_camel(path) if '_' in path else path - result = await accounts_service.gateway_client.update_config(network_id, camel_path, value) + result = check_gateway_error( + await accounts_service.gateway_client.update_config(network_id, camel_path, value) + ) results.append(result) return { @@ -490,6 +505,8 @@ async def update_network_config( except HTTPException: raise + except GatewayError as e: + raise HTTPException(status_code=e.status, detail=f"Gateway error updating network config: {e}") except Exception as e: raise HTTPException(status_code=500, detail=f"Error updating network config: {str(e)}") @@ -519,7 +536,7 @@ async def get_network_tokens( raise HTTPException(status_code=400, detail=f"Invalid network_id format: '{network_id}'. Use 'chain-network'") chain, network = parts - result = await accounts_service.gateway_client.get_tokens(chain, network) + result = check_gateway_error(await accounts_service.gateway_client.get_tokens(chain, network)) # Apply search filter if search and "tokens" in result: @@ -534,6 +551,8 @@ async def get_network_tokens( except HTTPException: raise + except GatewayError as e: + raise HTTPException(status_code=e.status, detail=f"Gateway error getting network tokens: {e}") except Exception as e: raise HTTPException(status_code=500, detail=f"Error getting network tokens: {str(e)}") @@ -575,17 +594,14 @@ async def add_network_token( # Use symbol as name if name is not provided token_name = token_request.name if token_request.name else token_request.symbol - result = await accounts_service.gateway_client.add_token( + check_gateway_error(await accounts_service.gateway_client.add_token( chain=chain, network=network, address=token_request.address, symbol=token_request.symbol, name=token_name, decimals=token_request.decimals - ) - - if "error" in result: - raise HTTPException(status_code=400, detail=f"Failed to add token: {result.get('error')}") + )) return { "success": True, @@ -599,6 +615,8 @@ async def add_network_token( except HTTPException: raise + except GatewayError as e: + raise HTTPException(status_code=e.status, detail=f"Failed to add token: {e}") except Exception as e: raise HTTPException(status_code=500, detail=f"Error adding token: {str(e)}") @@ -632,14 +650,11 @@ async def save_network_token( chain, network = parts - result = await accounts_service.gateway_client.save_token( + result = check_gateway_error(await accounts_service.gateway_client.save_token( chain=chain, network=network, token_address=token_address - ) - - if "error" in result: - raise HTTPException(status_code=400, detail=f"Failed to save token: {result.get('error')}") + )) token_info = result.get("token", {}) return { @@ -656,6 +671,8 @@ async def save_network_token( except HTTPException: raise + except GatewayError as e: + raise HTTPException(status_code=e.status, detail=f"Failed to save token: {e}") except Exception as e: raise HTTPException(status_code=500, detail=f"Error saving token: {str(e)}") @@ -688,14 +705,11 @@ async def delete_network_token( chain, network = parts - result = await accounts_service.gateway_client.delete_token( + check_gateway_error(await accounts_service.gateway_client.delete_token( chain=chain, network=network, token_address=token_address - ) - - if "error" in result: - raise HTTPException(status_code=400, detail=f"Failed to delete token: {result.get('error')}") + )) return { "success": True, @@ -706,6 +720,8 @@ async def delete_network_token( except HTTPException: raise + except GatewayError as e: + raise HTTPException(status_code=e.status, detail=f"Failed to delete token: {e}") except Exception as e: raise HTTPException(status_code=500, detail=f"Error deleting token: {str(e)}") @@ -743,13 +759,13 @@ async def get_network_pools( raise HTTPException(status_code=400, detail=f"Invalid network_id format: '{network_id}'. Use 'chain-network'") chain, network = parts - pools = await accounts_service.gateway_client.get_pools( + pools = check_gateway_error(await accounts_service.gateway_client.get_pools( chain=chain, network=network, connector=connector, pool_type=pool_type, search=search - ) + )) # Normalize each pool normalized_pools = [normalize_gateway_response(pool) for pool in pools] if pools else [] @@ -762,6 +778,8 @@ async def get_network_pools( except HTTPException: raise + except GatewayError as e: + raise HTTPException(status_code=e.status, detail=f"Gateway error getting network pools: {e}") except Exception as e: raise HTTPException(status_code=500, detail=f"Error getting network pools: {str(e)}") diff --git a/routers/gateway_clmm.py b/routers/gateway_clmm.py index 18554aeb..7e9c92ec 100644 --- a/routers/gateway_clmm.py +++ b/routers/gateway_clmm.py @@ -29,67 +29,13 @@ TimeBasedMetrics, ) from services.accounts_service import AccountsService +from services.gateway_client import GatewayError, check_gateway_error logger = logging.getLogger(__name__) router = APIRouter(tags=["Gateway CLMM"], prefix="/gateway") -async def fetch_pools_from_gateway( - gateway_url: str, - connector: str, - network: str = "mainnet-beta", - page: int = 0, - limit: int = 50, - query: Optional[str] = None, - sort_by: Optional[str] = None, - include_unverified: bool = True -) -> Optional[dict]: - """ - Fetch pools from Gateway's fetch-pools endpoint. - - Args: - gateway_url: Gateway base URL (e.g., http://localhost:15888) - connector: Connector name (meteora, orca) - network: Network ID (default: mainnet-beta) - page: Page number (0-based) - limit: Results per page - query: Search query to match pools by name, tokens, or address - sort_by: Sort by field - include_unverified: Include pools with unverified tokens - - Returns: - Dictionary with pools from Gateway, or None if failed - """ - try: - url = f"{gateway_url}/connectors/{connector}/clmm/fetch-pools" - params = { - "network": network, - "limit": limit, - } - - if page > 0: - params["page"] = page - if query: - params["query"] = query - if sort_by: - params["sortBy"] = sort_by - if not include_unverified: - params["includeUnverified"] = "false" - - async with aiohttp.ClientSession() as session: - async with session.get(url, params=params, headers={"accept": "application/json"}) as response: - response.raise_for_status() - data = await response.json() - return data - except aiohttp.ClientError as e: - logger.error(f"Failed to fetch pools from Gateway: {e}") - return None - except Exception as e: - logger.error(f"Error fetching pools from Gateway: {e}", exc_info=True) - return None - - async def fetch_raydium_pool_info(pool_address: str) -> Optional[dict]: """ Fetch pool info from Raydium API. @@ -240,12 +186,14 @@ async def _refresh_position_data(position, accounts_service: AccountsService, cl # Get all positions for this pool and find our specific position try: - positions_list = await accounts_service.gateway_client.clmm_positions_owned( + # check_gateway_error is critical here: a Gateway HTTP error must raise (and skip + # the refresh) rather than flow onward and mark the position CLOSED below. + positions_list = check_gateway_error(await accounts_service.gateway_client.clmm_positions_owned( connector=position.connector, chain_network=position.network, # position.network is already in 'chain-network' format wallet_address=wallet_address, pool_address=position.pool_address - ) + )) # Find our specific position in the list result = None @@ -371,18 +319,12 @@ async def get_clmm_pool_info( if not await accounts_service.gateway_client.ping(): raise HTTPException(status_code=503, detail="Gateway service is not available") - # Parse network_id - chain, network_name = accounts_service.gateway_client.parse_network_id(network) - - # Get pool info from Gateway using the CLMM-specific endpoint - result = await accounts_service.gateway_client.clmm_pool_info( + # Get pool info from Gateway's unified CLMM endpoint + result = check_gateway_error(await accounts_service.gateway_client.clmm_pool_info( connector=connector, - network=network_name, + chain_network=network, pool_address=pool_address - ) - - if result is None: - raise HTTPException(status_code=503, detail="Failed to get pool info from Gateway") + )) # Parse the camelCase Gateway response into snake_case Pydantic model # The model's aliases will handle the conversion @@ -390,6 +332,8 @@ async def get_clmm_pool_info( except HTTPException: raise + except GatewayError as e: + raise HTTPException(status_code=e.status, detail=f"Gateway error getting CLMM pool info: {e}") except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: @@ -436,9 +380,6 @@ async def get_clmm_pools( detail=f"Pool listing not supported for connector '{connector}'. Supported: {', '.join(supported_connectors)}" ) - # Get Gateway URL from accounts service - gateway_url = accounts_service.gateway_base_url - logger.info(f"Fetching pools from Gateway ({connector}, page={page}, limit={limit}, query={search_term})") # Build sort_by for Gateway (connector-specific format) @@ -451,18 +392,15 @@ async def get_clmm_pools( else: # orca sort_by = sort_key - gateway_data = await fetch_pools_from_gateway( - gateway_url=gateway_url, + gateway_data = check_gateway_error(await accounts_service.gateway_client.clmm_fetch_pools( connector=connector.lower(), + network="mainnet-beta", page=page, limit=limit, query=search_term, sort_by=sort_by, include_unverified=include_unknown - ) - - if gateway_data is None: - raise HTTPException(status_code=503, detail=f"Failed to fetch pools from Gateway for {connector}") + )) # Transform Gateway response to our format # Both Meteora and Orca now return same format: {pools: [...], total, page, pageSize} @@ -498,6 +436,8 @@ async def get_clmm_pools( except HTTPException: raise + except GatewayError as e: + raise HTTPException(status_code=e.status, detail=f"Gateway error getting CLMM pools: {e}") except Exception as e: logger.error(f"Error getting CLMM pools: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"Error getting CLMM pools: {str(e)}") @@ -532,7 +472,7 @@ async def open_clmm_position( raise HTTPException(status_code=503, detail="Gateway service is not available") # Parse network_id - chain, network = accounts_service.gateway_client.parse_network_id(request.network) + chain, _ = accounts_service.gateway_client.parse_network_id(request.network) # Get wallet address wallet_address = await accounts_service.gateway_client.get_wallet_address_or_default( @@ -540,12 +480,13 @@ async def open_clmm_position( wallet_address=request.wallet_address ) - # Get pool info to extract trading pair for database - pool_info = await accounts_service.gateway_client.clmm_pool_info( + # Get pool info to extract trading pair for database. Fail loud on Gateway errors — + # opening a position without knowing its tokens would corrupt the position record. + pool_info = check_gateway_error(await accounts_service.gateway_client.clmm_pool_info( connector=request.connector, - network=network, + chain_network=request.network, pool_address=request.pool_address - ) + )) # Extract tokens from pool info base_token_address = pool_info.get("baseTokenAddress", "") @@ -562,20 +503,18 @@ async def open_clmm_position( trading_pair = f"{base}-{quote}" # Open position - result = await accounts_service.gateway_client.clmm_open_position( + result = check_gateway_error(await accounts_service.gateway_client.clmm_open_position( connector=request.connector, - network=network, + chain_network=request.network, wallet_address=wallet_address, pool_address=request.pool_address, lower_price=float(request.lower_price), upper_price=float(request.upper_price), base_token_amount=float(request.base_token_amount) if request.base_token_amount else None, quote_token_amount=float(request.quote_token_amount) if request.quote_token_amount else None, - slippage_pct=float(request.slippage_pct) if request.slippage_pct else 1.0, + slippage_pct=float(request.slippage_pct) if request.slippage_pct is not None else 1.0, extra_params=request.extra_params - ) - if not result: - raise HTTPException(status_code=404, detail=f"Failed to open CLMM position: {trading_pair}") + )) transaction_hash = result.get("signature") or result.get("txHash") or result.get("hash") @@ -668,6 +607,8 @@ async def open_clmm_position( except HTTPException: raise + except GatewayError as e: + raise HTTPException(status_code=e.status, detail=f"Gateway error opening CLMM position: {e}") except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: @@ -701,7 +642,7 @@ async def add_liquidity_to_clmm_position( raise HTTPException(status_code=503, detail="Gateway service is not available") # Parse network_id - chain, network = accounts_service.gateway_client.parse_network_id(request.network) + chain, _ = accounts_service.gateway_client.parse_network_id(request.network) # Get wallet address wallet_address = await accounts_service.gateway_client.get_wallet_address_or_default( @@ -710,15 +651,15 @@ async def add_liquidity_to_clmm_position( ) # Add liquidity to existing position - result = await accounts_service.gateway_client.clmm_add_liquidity( + result = check_gateway_error(await accounts_service.gateway_client.clmm_add_liquidity( connector=request.connector, - network=network, + chain_network=request.network, wallet_address=wallet_address, position_address=request.position_address, base_token_amount=float(request.base_token_amount) if request.base_token_amount else None, quote_token_amount=float(request.quote_token_amount) if request.quote_token_amount else None, - slippage_pct=float(request.slippage_pct) if request.slippage_pct else 1.0 - ) + slippage_pct=float(request.slippage_pct) if request.slippage_pct is not None else 1.0 + )) transaction_hash = result.get("signature") or result.get("txHash") or result.get("hash") if not transaction_hash: @@ -763,6 +704,8 @@ async def add_liquidity_to_clmm_position( except HTTPException: raise + except GatewayError as e: + raise HTTPException(status_code=e.status, detail=f"Gateway error adding liquidity: {e}") except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: @@ -794,7 +737,7 @@ async def remove_liquidity_from_clmm_position( raise HTTPException(status_code=503, detail="Gateway service is not available") # Parse network_id - chain, network = accounts_service.gateway_client.parse_network_id(request.network) + chain, _ = accounts_service.gateway_client.parse_network_id(request.network) # Get wallet address wallet_address = await accounts_service.gateway_client.get_wallet_address_or_default( @@ -803,13 +746,13 @@ async def remove_liquidity_from_clmm_position( ) # Remove liquidity - result = await accounts_service.gateway_client.clmm_remove_liquidity( + result = check_gateway_error(await accounts_service.gateway_client.clmm_remove_liquidity( connector=request.connector, - network=network, + chain_network=request.network, wallet_address=wallet_address, position_address=request.position_address, percentage=float(request.percentage) - ) + )) transaction_hash = result.get("signature") or result.get("txHash") or result.get("hash") if not transaction_hash: @@ -854,6 +797,8 @@ async def remove_liquidity_from_clmm_position( except HTTPException: raise + except GatewayError as e: + raise HTTPException(status_code=e.status, detail=f"Gateway error removing liquidity: {e}") except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: @@ -884,7 +829,7 @@ async def close_clmm_position( raise HTTPException(status_code=503, detail="Gateway service is not available") # Parse network_id - chain, network = accounts_service.gateway_client.parse_network_id(request.network) + chain, _ = accounts_service.gateway_client.parse_network_id(request.network) # Get pool_address and wallet_address from database pool_address = None @@ -917,12 +862,12 @@ async def close_clmm_position( close_price = None try: - positions_list = await accounts_service.gateway_client.clmm_positions_owned( + positions_list = check_gateway_error(await accounts_service.gateway_client.clmm_positions_owned( connector=request.connector, chain_network=request.network, # request.network is already in 'chain-network' format wallet_address=wallet_address, pool_address=pool_address - ) + )) # Find our specific position and get pending fees and current price if positions_list and isinstance(positions_list, list): @@ -939,12 +884,12 @@ async def close_clmm_position( logger.warning(f"Could not fetch position state before closing: {e}", exc_info=True) # Close position - result = await accounts_service.gateway_client.clmm_close_position( + result = check_gateway_error(await accounts_service.gateway_client.clmm_close_position( connector=request.connector, - network=network, + chain_network=request.network, wallet_address=wallet_address, position_address=request.position_address - ) + )) transaction_hash = result.get("signature") or result.get("txHash") or result.get("hash") if not transaction_hash: @@ -1053,6 +998,8 @@ async def close_clmm_position( except HTTPException: raise + except GatewayError as e: + raise HTTPException(status_code=e.status, detail=f"Gateway error closing CLMM position: {e}") except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: @@ -1083,7 +1030,7 @@ async def collect_fees_from_clmm_position( raise HTTPException(status_code=503, detail="Gateway service is not available") # Parse network_id - chain, network = accounts_service.gateway_client.parse_network_id(request.network) + chain, _ = accounts_service.gateway_client.parse_network_id(request.network) # Get pool_address and wallet_address from database pool_address = None @@ -1115,12 +1062,12 @@ async def collect_fees_from_clmm_position( quote_fee_to_collect = Decimal("0") try: - positions_list = await accounts_service.gateway_client.clmm_positions_owned( + positions_list = check_gateway_error(await accounts_service.gateway_client.clmm_positions_owned( connector=request.connector, chain_network=request.network, # request.network is already in 'chain-network' format wallet_address=wallet_address, pool_address=pool_address - ) + )) # Find our specific position and get pending fees if positions_list and isinstance(positions_list, list): @@ -1136,15 +1083,12 @@ async def collect_fees_from_clmm_position( logger.warning(f"Could not fetch pending fees before collection: {e}", exc_info=True) # Collect fees - result = await accounts_service.gateway_client.clmm_collect_fees( + result = check_gateway_error(await accounts_service.gateway_client.clmm_collect_fees( connector=request.connector, - network=network, + chain_network=request.network, wallet_address=wallet_address, position_address=request.position_address - ) - - if not result: - raise HTTPException(status_code=500, detail="No response from Gateway collect-fees endpoint") + )) transaction_hash = result.get("signature") or result.get("txHash") or result.get("hash") if not transaction_hash: @@ -1215,6 +1159,8 @@ async def collect_fees_from_clmm_position( except HTTPException: raise + except GatewayError as e: + raise HTTPException(status_code=e.status, detail=f"Gateway error collecting fees: {e}") except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: @@ -1244,7 +1190,7 @@ async def get_clmm_positions_owned( raise HTTPException(status_code=503, detail="Gateway service is not available") # Parse network_id - chain, network = accounts_service.gateway_client.parse_network_id(request.network) + chain, _ = accounts_service.gateway_client.parse_network_id(request.network) # Get wallet address wallet_address = await accounts_service.gateway_client.get_wallet_address_or_default( @@ -1253,15 +1199,12 @@ async def get_clmm_positions_owned( ) # Get positions for the specified pool - result = await accounts_service.gateway_client.clmm_positions_owned( + result = check_gateway_error(await accounts_service.gateway_client.clmm_positions_owned( connector=request.connector, chain_network=request.network, # request.network is already in 'chain-network' format wallet_address=wallet_address, pool_address=request.pool_address - ) - - if result is None: - raise HTTPException(status_code=500, detail="Failed to get positions from Gateway") + )) # Gateway returns a list directly positions_data = result if isinstance(result, list) else [] @@ -1308,6 +1251,8 @@ async def get_clmm_positions_owned( except HTTPException: raise + except GatewayError as e: + raise HTTPException(status_code=e.status, detail=f"Gateway error getting CLMM positions owned: {e}") except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: diff --git a/routers/gateway_swap.py b/routers/gateway_swap.py index 335d9fd6..3a5816c6 100644 --- a/routers/gateway_swap.py +++ b/routers/gateway_swap.py @@ -1,23 +1,21 @@ """ Gateway Swap Router - Handles DEX swap operations via Hummingbot Gateway. -Supports Router connectors (Jupiter, 0x) for token swaps. +Uses Gateway's unified /trading/swap endpoints, so any swap provider works: +router connectors (jupiter, 0x, uniswap, pancakeswap) and amm/clmm connectors +(raydium/amm, meteora/clmm, ...). """ import logging -from typing import Optional from decimal import Decimal +from typing import Optional from fastapi import APIRouter, Depends, HTTPException -from deps import get_accounts_service, get_database_manager -from services.accounts_service import AccountsService from database import AsyncDatabaseManager from database.repositories import GatewaySwapRepository -from models import ( - SwapQuoteRequest, - SwapQuoteResponse, - SwapExecuteRequest, - SwapExecuteResponse, -) +from deps import get_accounts_service, get_database_manager +from models import SwapExecuteRequest, SwapExecuteResponse, SwapQuoteRequest, SwapQuoteResponse +from services.accounts_service import AccountsService +from services.gateway_client import GatewayError, check_gateway_error logger = logging.getLogger(__name__) @@ -52,10 +50,10 @@ async def get_swap_quote( accounts_service: AccountsService = Depends(get_accounts_service) ): """ - Get a price quote for a swap via router (Jupiter, 0x). + Get a price quote for a swap. Example: - connector: 'jupiter' + connector: 'jupiter' (or typed: 'jupiter/router', 'meteora/clmm', 'raydium/amm') network: 'solana-mainnet-beta' trading_pair: 'SOL-USDC' side: 'BUY' @@ -69,23 +67,19 @@ async def get_swap_quote( if not await accounts_service.gateway_client.ping(): raise HTTPException(status_code=503, detail="Gateway service is not available") - # Parse network_id - chain, network = accounts_service.gateway_client.parse_network_id(request.network) - # Parse trading pair base, quote = request.trading_pair.split("-") # Get quote from Gateway - result = await accounts_service.gateway_client.quote_swap( + result = check_gateway_error(await accounts_service.gateway_client.quote_swap( connector=request.connector, - network=network, + chain_network=request.network, base_asset=base, quote_asset=quote, amount=float(request.amount), side=request.side, - slippage_pct=float(request.slippage_pct) if request.slippage_pct else 1.0, - pool_address=None - ) + slippage_pct=float(request.slippage_pct) if request.slippage_pct is not None else 1.0 + )) # Extract amounts from Gateway response (snake_case for consistency) amount_in_raw = result.get("amountIn") or result.get("amount_in") @@ -106,12 +100,14 @@ async def get_swap_quote( amount_in=amount_in, amount_out=amount_out, expected_amount=amount_out, # Deprecated, kept for backward compatibility - slippage_pct=request.slippage_pct or Decimal("1.0"), + slippage_pct=request.slippage_pct if request.slippage_pct is not None else Decimal("1.0"), gas_estimate=gas_estimate_value ) except HTTPException: raise + except GatewayError as e: + raise HTTPException(status_code=e.status, detail=f"Gateway error getting swap quote: {e}") except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: @@ -144,7 +140,7 @@ async def execute_swap( if not await accounts_service.gateway_client.ping(): raise HTTPException(status_code=503, detail="Gateway service is not available") - # Parse network_id + # Parse network_id (chain needed for wallet lookup) chain, network = accounts_service.gateway_client.parse_network_id(request.network) # Get wallet address @@ -157,18 +153,16 @@ async def execute_swap( base, quote = request.trading_pair.split("-") # Execute swap - result = await accounts_service.gateway_client.execute_swap( + result = check_gateway_error(await accounts_service.gateway_client.execute_swap( connector=request.connector, - network=network, + chain_network=request.network, wallet_address=wallet_address, base_asset=base, quote_asset=quote, amount=float(request.amount), side=request.side, - slippage_pct=float(request.slippage_pct) if request.slippage_pct else 1.0 - ) - if not result: - raise HTTPException(status_code=500, detail="Gateway service is not able to execute swap") + slippage_pct=float(request.slippage_pct) if request.slippage_pct is not None else 1.0 + )) transaction_hash = result.get("signature") or result.get("txHash") or result.get("hash") if not transaction_hash: raise HTTPException(status_code=500, detail="No transaction hash returned from Gateway") @@ -207,7 +201,7 @@ async def execute_swap( "input_amount": float(input_amount), "output_amount": float(output_amount), "price": float(price), - "slippage_pct": float(request.slippage_pct) if request.slippage_pct else 1.0, + "slippage_pct": float(request.slippage_pct) if request.slippage_pct is not None else 1.0, "status": tx_status, "pool_address": result.get("poolAddress") or result.get("pool_address") } @@ -228,6 +222,8 @@ async def execute_swap( except HTTPException: raise + except GatewayError as e: + raise HTTPException(status_code=e.status, detail=f"Gateway error executing swap: {e}") except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: diff --git a/services/accounts_service.py b/services/accounts_service.py index 7ee70d4d..e1ce570f 100644 --- a/services/accounts_service.py +++ b/services/accounts_service.py @@ -249,10 +249,17 @@ async def update_account_state_loop(self): tasks = [] task_meta = [] # (account_name, connector_name) + gateway_meta = [] # (account_name, connector_name) of Gateway connectors + for account_name, connectors in all_connectors.items(): if account_name not in self.accounts_state: self.accounts_state[account_name] = {} for connector_name, connector in connectors.items(): + # Gateway connectors only track the tokens of their trading pairs; the + # full wallet balance is fetched by _update_gateway_balances below. + if self._connector_service.is_gateway_connector(connector): + gateway_meta.append((account_name, connector_name)) + continue tasks.append(self._refresh_and_get_tokens_info(connector, connector_name, account_name)) task_meta.append((account_name, connector_name)) @@ -273,6 +280,8 @@ async def update_account_state_loop(self): if isinstance(gw_result, Exception): logger.error(f"Error updating gateway balances: {gw_result}") + self._mirror_gateway_state_to_accounts(gateway_meta) + await self.dump_account_state() except Exception as e: logger.error(f"Error updating account state: {e}") @@ -406,6 +415,7 @@ async def update_account_state( # Prepare parallel tasks tasks = [] task_meta = [] # (account_name, connector_name) + gateway_meta = [] # (account_name, connector_name) of Gateway connectors for account_name, connectors in all_connectors.items(): # Filter by account_names if specified @@ -419,6 +429,13 @@ async def update_account_state( if connector_names and connector_name not in connector_names: continue + # Gateway connectors only track the tokens of their trading pairs; the full + # wallet balance is fetched by _update_gateway_balances below. Running both + # would overwrite the complete wallet state with the narrower connector view. + if self._connector_service.is_gateway_connector(connector): + gateway_meta.append((account_name, connector_name)) + continue + tasks.append(self._get_connector_tokens_info(connector, connector_name)) task_meta.append((account_name, connector_name)) @@ -443,6 +460,26 @@ async def update_account_state( else: self.accounts_state[account_name][connector_name] = result + if not skip_gateway: + self._mirror_gateway_state_to_accounts(gateway_meta) + + def _mirror_gateway_state_to_accounts(self, gateway_meta: List[tuple]): + """Mirror Gateway wallet balances onto non-master accounts holding the same connector. + + _update_gateway_balances stores the full wallet balances under master_account. A + Gateway connector configured on another account trades the same Gateway default + wallet, so it reports the same balances. + + Args: + gateway_meta: (account_name, connector_name) pairs of Gateway connectors. + """ + master_state = self.accounts_state.get("master_account", {}) + for account_name, connector_name in gateway_meta: + if account_name == "master_account": + continue + if connector_name in master_state: + self.accounts_state[account_name][connector_name] = master_state[connector_name] + async def _get_connector_tokens_info(self, connector, connector_name: str, skip_balance_refresh: bool = False) -> List[Dict]: """Get token info from a connector instance using the ticker pool prices. @@ -1111,6 +1148,11 @@ async def _update_gateway_balances(self, chain_networks: Optional[List[str]] = N logger.warning(f"Could not get config for '{chain}-{first_network}': {config}") continue + # A Gateway HTTP error dict would otherwise read as "missing defaultWallet" below + if config is None or set(config.keys()) == {"error", "status"}: + logger.warning(f"Gateway error getting config for '{chain}-{first_network}': {config}") + continue + default_wallet = config.get("defaultWallet") default_networks = config.get("defaultNetworks", []) diff --git a/services/gateway_client.py b/services/gateway_client.py index 62cf238a..d354039d 100644 --- a/services/gateway_client.py +++ b/services/gateway_client.py @@ -6,6 +6,34 @@ logger = logging.getLogger(__name__) +# Connectors whose bare name maps to a router-type swap provider on Gateway. +# All other connectors default to their CLMM route (meteora, orca, raydium, pancakeswap-sol). +ROUTER_CONNECTORS = {"jupiter", "0x", "uniswap", "pancakeswap"} + + +class GatewayError(Exception): + """A Gateway HTTP request that completed with a non-OK status.""" + + def __init__(self, message: str, status: int): + super().__init__(message) + self.status = status + + +def check_gateway_error(result: Optional[Any]) -> Any: + """ + Validate a GatewayClient response, raising instead of letting error shapes flow onward. + + ``_request`` returns ``{"error": , "status": }`` on non-OK HTTP responses and + ``None`` on connection errors. Callers that treat those as data silently corrupt results + (e.g. rendering a 404 as price 0), so pass every response through this helper unless the + caller explicitly branches on the error shape. + """ + if result is None: + raise GatewayError("No response from Gateway (connection error)", 503) + if isinstance(result, dict) and set(result.keys()) == {"error", "status"}: + raise GatewayError(str(result["error"]), int(result["status"])) + return result + class GatewayClient: """ @@ -241,6 +269,10 @@ async def get_chains(self) -> Dict: """Get available chains""" return await self._request("GET", "config/chains") + async def get_connectors(self) -> Dict: + """Get available connectors with their trading types""" + return await self._request("GET", "config/connectors") + async def get_default_network(self, chain: str) -> Optional[str]: """Get default network for a chain""" try: @@ -383,48 +415,59 @@ async def delete_pool(self, chain: str, network: str, address: str) -> Dict: "network": network }) - async def pool_info(self, connector: str, network: str, pool_address: str) -> Dict: - """Get detailed information about a specific pool""" - return await self._request("POST", "clmm/liquidity/pool", json={ - "connector": connector, - "network": network, - "poolAddress": pool_address - }) - # ============================================ - # Swap Operations + # Swap Operations (unified /trading/swap endpoints) # ============================================ + @staticmethod + def normalize_swap_connector(connector: str) -> str: + """ + Normalize a connector name to Gateway's 'name/type' swap-provider format. + + 'jupiter' -> 'jupiter/router', 'meteora' -> 'meteora/clmm', + 'raydium/amm' -> 'raydium/amm' (already typed, passed through). + """ + if "/" in connector: + return connector + connector_type = "router" if connector in ROUTER_CONNECTORS else "clmm" + return f"{connector}/{connector_type}" + async def quote_swap( self, connector: str, - network: str, + chain_network: str, base_asset: str, quote_asset: str, amount: float, side: str, - slippage_pct: Optional[float] = None, - pool_address: Optional[str] = None + slippage_pct: Optional[float] = None ) -> Dict: - """Get a quote for a swap""" - payload = { - "network": network, + """ + Get a swap quote via Gateway's unified /trading/swap/quote endpoint. + + Args: + connector: Swap provider, either bare ('jupiter', 'meteora') or typed + ('jupiter/router', 'raydium/amm', 'meteora/clmm'). + chain_network: 'chain-network' format (e.g. 'solana-mainnet-beta'). + For amm/clmm providers Gateway resolves the pool from its pool list. + """ + params = { + "chainNetwork": chain_network, + "connector": self.normalize_swap_connector(connector), "baseToken": base_asset, "quoteToken": quote_asset, "amount": str(amount), "side": side.upper() } if slippage_pct is not None: - payload["slippagePct"] = slippage_pct - if pool_address: - payload["poolAddress"] = pool_address + params["slippagePct"] = str(slippage_pct) - return await self._request("GET", f"connectors/{connector}/router/quote-swap", params=payload) + return await self._request("GET", "trading/swap/quote", params=params) async def execute_swap( self, connector: str, - network: str, + chain_network: str, wallet_address: str, base_asset: str, quote_asset: str, @@ -432,42 +475,29 @@ async def execute_swap( side: str, slippage_pct: Optional[float] = None ) -> Dict: - """Execute a swap""" + """Execute a swap via Gateway's unified /trading/swap/execute endpoint.""" payload = { - "network": network, + "chainNetwork": chain_network, + "connector": self.normalize_swap_connector(connector), "walletAddress": wallet_address, "baseToken": base_asset, "quoteToken": quote_asset, - "amount": str(amount), + "amount": amount, "side": side.upper() } if slippage_pct is not None: payload["slippagePct"] = slippage_pct - return await self._request("POST", f"connectors/{connector}/router/execute-swap", json=payload) - - async def execute_quote( - self, - connector: str, - network: str, - wallet_address: str, - quote_id: str - ) -> Dict: - """Execute a previously obtained quote""" - return await self._request("POST", f"connectors/{connector}/router/execute-quote", json={ - "network": network, - "address": wallet_address, - "quoteId": quote_id - }) + return await self._request("POST", "trading/swap/execute", json=payload) # ============================================ - # Liquidity Operations - CLMM (Concentrated Liquidity) + # Liquidity Operations - CLMM (unified /trading/clmm endpoints) # ============================================ async def clmm_open_position( self, connector: str, - network: str, + chain_network: str, wallet_address: str, pool_address: str, lower_price: float, @@ -479,29 +509,30 @@ async def clmm_open_position( ) -> Dict: """Open a NEW CLMM position with initial liquidity""" payload = { - "network": network, + "connector": connector, + "chainNetwork": chain_network, "walletAddress": wallet_address, "poolAddress": pool_address, "lowerPrice": lower_price, "upperPrice": upper_price } if base_token_amount is not None: - payload["baseTokenAmount"] = str(base_token_amount) + payload["baseTokenAmount"] = base_token_amount if quote_token_amount is not None: - payload["quoteTokenAmount"] = str(quote_token_amount) + payload["quoteTokenAmount"] = quote_token_amount if slippage_pct is not None: payload["slippagePct"] = slippage_pct - # Add any connector-specific parameters + # Connector-specific parameters (e.g. Meteora's strategyType) if extra_params: payload.update(extra_params) - return await self._request("POST", f"connectors/{connector}/clmm/open-position", json=payload) + return await self._request("POST", "trading/clmm/open", json=payload) async def clmm_add_liquidity( self, connector: str, - network: str, + chain_network: str, wallet_address: str, position_address: str, base_token_amount: Optional[float] = None, @@ -511,29 +542,30 @@ async def clmm_add_liquidity( """Add more liquidity to an existing CLMM position""" payload = { "connector": connector, - "network": network, - "address": wallet_address, + "chainNetwork": chain_network, + "walletAddress": wallet_address, "positionAddress": position_address } if base_token_amount is not None: - payload["baseTokenAmount"] = str(base_token_amount) + payload["baseTokenAmount"] = base_token_amount if quote_token_amount is not None: - payload["quoteTokenAmount"] = str(quote_token_amount) + payload["quoteTokenAmount"] = quote_token_amount if slippage_pct is not None: payload["slippagePct"] = slippage_pct - return await self._request("POST", "clmm/liquidity/add", json=payload) + return await self._request("POST", "trading/clmm/add", json=payload) async def clmm_close_position( self, connector: str, - network: str, + chain_network: str, wallet_address: str, position_address: str ) -> Dict: """Close a CLMM position completely""" - return await self._request("POST", f"connectors/{connector}/clmm/close-position", json={ - "network": network, + return await self._request("POST", "trading/clmm/close", json={ + "connector": connector, + "chainNetwork": chain_network, "walletAddress": wallet_address, "positionAddress": position_address }) @@ -541,18 +573,18 @@ async def clmm_close_position( async def clmm_remove_liquidity( self, connector: str, - network: str, + chain_network: str, wallet_address: str, position_address: str, percentage: float ) -> Dict: """Remove liquidity from a CLMM position (partial)""" - return await self._request("POST", "clmm/liquidity/remove", json={ + return await self._request("POST", "trading/clmm/remove", json={ "connector": connector, - "network": network, - "address": wallet_address, + "chainNetwork": chain_network, + "walletAddress": wallet_address, "positionAddress": position_address, - "percentage": percentage + "percentageToRemove": percentage }) async def clmm_position_info( @@ -624,29 +656,62 @@ async def clmm_positions_owned( async def clmm_collect_fees( self, connector: str, - network: str, + chain_network: str, wallet_address: str, position_address: str ) -> Dict: """Collect accumulated fees from a CLMM position""" - return await self._request("POST", f"connectors/{connector}/clmm/collect-fees", json={ - "network": network, - "address": wallet_address, + return await self._request("POST", "trading/clmm/collect-fees", json={ + "connector": connector, + "chainNetwork": chain_network, + "walletAddress": wallet_address, "positionAddress": position_address }) async def clmm_pool_info( self, connector: str, - network: str, + chain_network: str, pool_address: str ) -> Dict: """Get detailed CLMM pool information by pool address""" - return await self._request("GET", f"connectors/{connector}/clmm/pool-info", params={ - "network": network, + return await self._request("GET", "trading/clmm/pool-info", params={ + "connector": connector, + "chainNetwork": chain_network, "poolAddress": pool_address }) + async def clmm_fetch_pools( + self, + connector: str, + network: str, + page: int = 0, + limit: int = 50, + query: Optional[str] = None, + sort_by: Optional[str] = None, + include_unverified: bool = True + ) -> Dict: + """ + Discover CLMM pools from the connector's own listing API (meteora, orca). + + This is a per-connector Gateway route (no unified equivalent): it proxies the + DEX's pool-discovery API rather than Gateway's saved pool list. + """ + params = { + "network": network, + "limit": limit, + } + if page > 0: + params["page"] = page + if query: + params["query"] = query + if sort_by: + params["sortBy"] = sort_by + if not include_unverified: + params["includeUnverified"] = "false" + + return await self._request("GET", f"connectors/{connector}/clmm/fetch-pools", params=params) + # ============================================ # Transaction Polling # ============================================ diff --git a/services/gateway_service.py b/services/gateway_service.py index 73798bb7..c1151e16 100644 --- a/services/gateway_service.py +++ b/services/gateway_service.py @@ -7,6 +7,7 @@ import docker from docker.errors import DockerException from docker.types import LogConfig +from hummingbot.core.gateway.gateway_http_client import GatewayHttpClient from config import settings from models.gateway import GatewayConfig, GatewayStatus @@ -171,7 +172,7 @@ def start(self, config: GatewayConfig) -> Dict[str, Any]: if existing_container.status == "running": return { "success": False, - "message": f"Gateway is already running. Use stop first or restart to update configuration." + "message": "Gateway is already running. Use stop first or restart to update configuration." } else: # Remove stopped container @@ -285,9 +286,15 @@ def start(self, config: GatewayConfig) -> Dict[str, Any]: ) logger.info(f"Gateway container started successfully: {container.id}") + + # On a fresh install the app lifespan defers the Gateway status monitor + # because the mTLS certs didn't exist yet; they were just generated above, + # so start it now (no-op when it is already running). + GatewayHttpClient.get_instance().start_monitor() + return { "success": True, - "message": f"Gateway started successfully", + "message": "Gateway started successfully", "container_id": container.id, "port": config.port } diff --git a/services/gateway_transaction_poller.py b/services/gateway_transaction_poller.py index 8c30bc5a..2781043e 100644 --- a/services/gateway_transaction_poller.py +++ b/services/gateway_transaction_poller.py @@ -299,6 +299,15 @@ async def _check_transaction_status( logger.warning(f"Invalid response from Gateway for transaction {tx_hash} on {network_id}: {result}") return None + # A Gateway HTTP error (the client's {"error", "status"} shape) is transient — treat it + # like "no response" rather than letting its 'error' key mark the transaction FAILED below. + if set(result.keys()) == {"error", "status"}: + logger.warning( + f"Gateway HTTP error polling transaction {tx_hash} on {network_id} " + f"(status {result['status']}): {result['error']}" + ) + return None + logger.debug(f"Polled transaction {tx_hash} on {network_id}: txStatus={result.get('txStatus')}") # Parse the response with defensive checks diff --git a/services/gateway_wallet_service.py b/services/gateway_wallet_service.py index e896ae57..c12d84e3 100644 --- a/services/gateway_wallet_service.py +++ b/services/gateway_wallet_service.py @@ -5,7 +5,7 @@ from fastapi import HTTPException -from services.gateway_client import GatewayClient +from services.gateway_client import GatewayClient, check_gateway_error from services.gecko_price_source import GeckoPriceSource # Create module-specific logger @@ -72,7 +72,7 @@ async def get_gateway_wallets(self) -> List[Dict]: await self._require_gateway() try: - wallets = await self.gateway_client.get_wallets() + wallets = check_gateway_error(await self.gateway_client.get_wallets()) # Enrich with default wallet info for each chain for wallet_group in wallets: diff --git a/services/gecko_price_source.py b/services/gecko_price_source.py index ddfa9f7e..04dd2a26 100644 --- a/services/gecko_price_source.py +++ b/services/gecko_price_source.py @@ -25,6 +25,8 @@ from decimal import Decimal, InvalidOperation from typing import Dict, List, Optional, Tuple +from services.gateway_client import GatewayError, check_gateway_error + logger = logging.getLogger(__name__) # GeckoTerminal accepts at most 30 token addresses per simple-token-price call. @@ -124,7 +126,13 @@ async def _symbol_to_address(self, chain: str, network: str) -> Dict[str, str]: if cached is not None and (time.time() - cached[0]) < _TOKEN_LIST_TTL: return cached[1] - response = await self._gateway_client.get_tokens(chain, network) + try: + response = check_gateway_error(await self._gateway_client.get_tokens(chain, network)) + except GatewayError as e: + # Don't cache on Gateway errors — caching an empty map would silently disable + # Gecko pricing for the whole TTL window. + logger.warning(f"Gateway error fetching token list for {chain}/{network}: {e}") + return {} tokens = response.get("tokens", []) if isinstance(response, dict) else [] mapping: Dict[str, str] = {} for token in tokens: diff --git a/services/unified_connector_service.py b/services/unified_connector_service.py index 47f983ff..09265770 100644 --- a/services/unified_connector_service.py +++ b/services/unified_connector_service.py @@ -92,6 +92,17 @@ def _is_perpetual_connector(self, connector: ConnectorBase) -> bool: """ return isinstance(connector, PerpetualDerivativePyBase) + def is_gateway_connector(self, connector: ConnectorBase) -> bool: + """Check if connector is a unified Gateway (on-chain) connector. + + Args: + connector: The connector instance to check + + Returns: + True if Gateway connector, False otherwise + """ + return isinstance(connector, Gateway) + # ========================================================================= # Trading Connector Management (authenticated, per-account) # ========================================================================= diff --git a/test/test_gateway_client_contract.py b/test/test_gateway_client_contract.py new file mode 100644 index 00000000..5faa3182 --- /dev/null +++ b/test/test_gateway_client_contract.py @@ -0,0 +1,238 @@ +""" +Regression tests for the Gateway HTTP contract (paths, payload keys, error handling). + +These pin the client to the Gateway route table verified live on 2026-07-13 +(hummingbot/gateway feat-robinhood-chain): +- swaps go through the unified /trading/swap endpoints (NOT /connectors/{c}/router/..., + which 404s for clmm-only connectors like meteora and doubles the path for + connector values like "jupiter/router"), +- CLMM ops go through the unified /trading/clmm endpoints with camelCase keys + (chainNetwork/walletAddress/percentageToRemove — NOT the legacy + /clmm/liquidity/* paths, which do not exist on Gateway), +- non-OK Gateway responses surface as GatewayError instead of flowing onward + as data (the "404 rendered as price 0" class of bug). + +Run with: pytest test/test_gateway_client_contract.py -v +""" +import pytest + +from services.gateway_client import GatewayClient, GatewayError, check_gateway_error + + +@pytest.fixture +def client_and_calls(monkeypatch): + """A GatewayClient whose _request records calls instead of hitting the network.""" + client = GatewayClient() + calls = [] + + async def fake_request(method, path, params=None, json=None): + calls.append({"method": method, "path": path, "params": params, "json": json}) + return {} + + monkeypatch.setattr(client, "_request", fake_request) + return client, calls + + +# ============================================ +# Connector normalization +# ============================================ + +@pytest.mark.parametrize("connector,expected", [ + ("jupiter", "jupiter/router"), + ("0x", "0x/router"), + ("uniswap", "uniswap/router"), + ("pancakeswap", "pancakeswap/router"), + ("meteora", "meteora/clmm"), + ("orca", "orca/clmm"), + ("raydium", "raydium/clmm"), + ("pancakeswap-sol", "pancakeswap-sol/clmm"), + # Already-typed providers pass through untouched (no doubled /router/router) + ("jupiter/router", "jupiter/router"), + ("meteora/clmm", "meteora/clmm"), + ("raydium/amm", "raydium/amm"), +]) +def test_normalize_swap_connector(connector, expected): + assert GatewayClient.normalize_swap_connector(connector) == expected + + +# ============================================ +# Swap paths and payloads (unified /trading/swap) +# ============================================ + +@pytest.mark.asyncio +async def test_quote_swap_uses_unified_endpoint(client_and_calls): + client, calls = client_and_calls + await client.quote_swap( + connector="meteora", chain_network="solana-mainnet-beta", + base_asset="SOL", quote_asset="USDC", amount=0.1, side="sell", + slippage_pct=1.0, + ) + call = calls[0] + assert call["method"] == "GET" + assert call["path"] == "trading/swap/quote" + assert call["params"] == { + "chainNetwork": "solana-mainnet-beta", + "connector": "meteora/clmm", + "baseToken": "SOL", + "quoteToken": "USDC", + "amount": "0.1", + "side": "SELL", + "slippagePct": "1.0", + } + + +@pytest.mark.asyncio +async def test_execute_swap_uses_unified_endpoint(client_and_calls): + client, calls = client_and_calls + await client.execute_swap( + connector="jupiter/router", chain_network="solana-mainnet-beta", + wallet_address="WALLET", base_asset="SOL", quote_asset="USDC", + amount=0.1, side="buy", + ) + call = calls[0] + assert call["method"] == "POST" + assert call["path"] == "trading/swap/execute" + assert call["json"]["chainNetwork"] == "solana-mainnet-beta" + assert call["json"]["connector"] == "jupiter/router" + assert call["json"]["walletAddress"] == "WALLET" + assert call["json"]["side"] == "BUY" + + +@pytest.mark.asyncio +async def test_quote_swap_zero_slippage_is_sent(client_and_calls): + """slippage_pct=0 must reach Gateway as 0, not be dropped as falsy.""" + client, calls = client_and_calls + await client.quote_swap( + connector="jupiter", chain_network="solana-mainnet-beta", + base_asset="SOL", quote_asset="USDC", amount=1, side="BUY", + slippage_pct=0, + ) + assert calls[0]["params"]["slippagePct"] == "0" + + +# ============================================ +# CLMM paths and payloads (unified /trading/clmm) +# ============================================ + +@pytest.mark.asyncio +async def test_clmm_open_position_path_and_keys(client_and_calls): + client, calls = client_and_calls + await client.clmm_open_position( + connector="meteora", chain_network="solana-mainnet-beta", + wallet_address="WALLET", pool_address="POOL", + lower_price=150.0, upper_price=250.0, + base_token_amount=0.01, quote_token_amount=2.0, slippage_pct=1.0, + extra_params={"strategyType": 0}, + ) + call = calls[0] + assert (call["method"], call["path"]) == ("POST", "trading/clmm/open") + body = call["json"] + assert body["connector"] == "meteora" + assert body["chainNetwork"] == "solana-mainnet-beta" + assert body["walletAddress"] == "WALLET" + assert body["poolAddress"] == "POOL" + assert body["strategyType"] == 0 + # Gateway's unified schema wants numbers, not strings + assert body["baseTokenAmount"] == 0.01 + assert body["quoteTokenAmount"] == 2.0 + + +@pytest.mark.asyncio +async def test_clmm_add_liquidity_path_and_keys(client_and_calls): + client, calls = client_and_calls + await client.clmm_add_liquidity( + connector="meteora", chain_network="solana-mainnet-beta", + wallet_address="WALLET", position_address="POS", + base_token_amount=0.5, quote_token_amount=50.0, slippage_pct=1.0, + ) + call = calls[0] + assert (call["method"], call["path"]) == ("POST", "trading/clmm/add") + assert call["json"]["walletAddress"] == "WALLET" + assert call["json"]["chainNetwork"] == "solana-mainnet-beta" + + +@pytest.mark.asyncio +async def test_clmm_remove_liquidity_uses_percentage_to_remove(client_and_calls): + client, calls = client_and_calls + await client.clmm_remove_liquidity( + connector="meteora", chain_network="solana-mainnet-beta", + wallet_address="WALLET", position_address="POS", percentage=50.0, + ) + call = calls[0] + assert (call["method"], call["path"]) == ("POST", "trading/clmm/remove") + assert call["json"]["percentageToRemove"] == 50.0 + assert "percentage" not in call["json"] + + +@pytest.mark.asyncio +async def test_clmm_close_and_collect_use_wallet_address_key(client_and_calls): + """Gateway's schemas default walletAddress when absent — sending the wrong + key ('address') silently operates on the default wallet.""" + client, calls = client_and_calls + await client.clmm_close_position( + connector="meteora", chain_network="solana-mainnet-beta", + wallet_address="WALLET", position_address="POS", + ) + await client.clmm_collect_fees( + connector="meteora", chain_network="solana-mainnet-beta", + wallet_address="WALLET", position_address="POS", + ) + assert (calls[0]["method"], calls[0]["path"]) == ("POST", "trading/clmm/close") + assert (calls[1]["method"], calls[1]["path"]) == ("POST", "trading/clmm/collect-fees") + for call in calls: + assert call["json"]["walletAddress"] == "WALLET" + assert "address" not in call["json"] + + +@pytest.mark.asyncio +async def test_clmm_pool_info_uses_unified_endpoint(client_and_calls): + client, calls = client_and_calls + await client.clmm_pool_info( + connector="meteora", chain_network="solana-mainnet-beta", pool_address="POOL", + ) + call = calls[0] + assert (call["method"], call["path"]) == ("GET", "trading/clmm/pool-info") + assert call["params"] == { + "connector": "meteora", + "chainNetwork": "solana-mainnet-beta", + "poolAddress": "POOL", + } + + +@pytest.mark.asyncio +async def test_clmm_fetch_pools_path(client_and_calls): + client, calls = client_and_calls + await client.clmm_fetch_pools(connector="meteora", network="mainnet-beta", limit=10) + call = calls[0] + assert (call["method"], call["path"]) == ("GET", "connectors/meteora/clmm/fetch-pools") + + +# ============================================ +# Error-shape detection (check_gateway_error) +# ============================================ + +def test_check_gateway_error_raises_on_error_dict(): + with pytest.raises(GatewayError) as exc: + check_gateway_error({"error": "Route not found", "status": 404}) + assert exc.value.status == 404 + assert "Route not found" in str(exc.value) + + +def test_check_gateway_error_raises_on_none(): + with pytest.raises(GatewayError) as exc: + check_gateway_error(None) + assert exc.value.status == 503 + + +def test_check_gateway_error_passes_valid_payloads(): + quote = {"price": 75.2, "amountIn": 7.52, "amountOut": 0.1} + assert check_gateway_error(quote) is quote + positions = [{"address": "POS"}] + assert check_gateway_error(positions) is positions + + +def test_check_gateway_error_ignores_legit_error_fields(): + """Poll responses legitimately contain an 'error' key next to tx data — + only the exact {'error','status'} shape is the client's HTTP-error marker.""" + poll = {"txStatus": -1, "error": "SLIPPAGE_EXCEEDED (0x1771)", "signature": "abc", "fee": 0.1} + assert check_gateway_error(poll) is poll diff --git a/test/test_gateway_error_masking.py b/test/test_gateway_error_masking.py new file mode 100644 index 00000000..0a306428 --- /dev/null +++ b/test/test_gateway_error_masking.py @@ -0,0 +1,164 @@ +""" +Regression tests for Gateway error-dict masking. + +GatewayClient._request returns {"error": , "status": } on non-OK HTTP. +These tests pin the three places where treating that shape as data caused real +damage (found in the 2026-07-13 audit): +- /gateway/swap/quote rendered a Gateway 404 as a 200 quote with price "0", +- _refresh_position_data marked positions CLOSED in the DB on any Gateway error, +- the transaction poller recorded transient Gateway errors as on-chain FAILED. + +Run with: pytest test/test_gateway_error_masking.py -v +""" +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +ERROR_DICT = {"error": "Route GET:/nope not found", "status": 404} + + +def _mock_accounts_service(**gateway_client_methods): + gateway_client = SimpleNamespace( + ping=AsyncMock(return_value=True), + parse_network_id=lambda network_id: tuple(network_id.split("-", 1)), + **{name: AsyncMock(return_value=value) for name, value in gateway_client_methods.items()}, + ) + return SimpleNamespace(gateway_client=gateway_client) + + +# ============================================ +# /gateway/swap/quote must not mask errors as price "0" +# ============================================ + +@pytest.fixture +def swap_app(): + from deps import get_accounts_service + from routers import gateway_swap + + app = FastAPI() + app.include_router(gateway_swap.router) + + def with_service(service): + app.dependency_overrides[get_accounts_service] = lambda: service + return TestClient(app, raise_server_exceptions=False) + + return with_service + + +def _quote_body(connector="jupiter"): + return { + "connector": connector, + "network": "solana-mainnet-beta", + "trading_pair": "SOL-USDC", + "side": "BUY", + "amount": 0.1, + } + + +def test_swap_quote_propagates_gateway_error(swap_app): + client = swap_app(_mock_accounts_service(quote_swap=ERROR_DICT)) + response = client.post("/gateway/swap/quote", json=_quote_body()) + assert response.status_code == 404 + assert "not found" in response.json()["detail"] + + +def test_swap_quote_happy_path_returns_real_price(swap_app): + quote = {"price": 75.2, "amountIn": 7.52, "amountOut": 0.1} + client = swap_app(_mock_accounts_service(quote_swap=quote)) + response = client.post("/gateway/swap/quote", json=_quote_body("meteora/clmm")) + assert response.status_code == 200 + assert response.json()["price"] == "75.2" + + +def test_swap_quote_gateway_unreachable_is_503(swap_app): + client = swap_app(_mock_accounts_service(quote_swap=None)) + response = client.post("/gateway/swap/quote", json=_quote_body()) + assert response.status_code == 503 + + +# ============================================ +# _refresh_position_data must not close positions on Gateway errors +# ============================================ + +def _position(): + return SimpleNamespace( + connector="meteora", + network="solana-mainnet-beta", + wallet_address="WALLET", + pool_address="POOL", + position_address="POS", + ) + + +@pytest.mark.asyncio +async def test_refresh_does_not_close_position_on_gateway_error(): + from routers.gateway_clmm import _refresh_position_data + + accounts_service = _mock_accounts_service(clmm_positions_owned=ERROR_DICT) + clmm_repo = SimpleNamespace( + close_position=AsyncMock(), + update_position_liquidity=AsyncMock(), + update_position_fees=AsyncMock(), + ) + + await _refresh_position_data(_position(), accounts_service, clmm_repo) + clmm_repo.close_position.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_refresh_closes_position_missing_from_valid_list(): + from routers.gateway_clmm import _refresh_position_data + + accounts_service = _mock_accounts_service(clmm_positions_owned=[{"address": "OTHER"}]) + clmm_repo = SimpleNamespace( + close_position=AsyncMock(), + update_position_liquidity=AsyncMock(), + update_position_fees=AsyncMock(), + ) + + await _refresh_position_data(_position(), accounts_service, clmm_repo) + clmm_repo.close_position.assert_awaited_once_with("POS") + + +# ============================================ +# Transaction poller must treat Gateway errors as transient, not FAILED +# ============================================ + +def _poller_with_result(result): + from services.gateway_transaction_poller import GatewayTransactionPoller + + poller = object.__new__(GatewayTransactionPoller) + poller.gateway_client = SimpleNamespace( + ping=AsyncMock(return_value=True), + poll_transaction=AsyncMock(return_value=result), + ) + return poller + + +@pytest.mark.asyncio +async def test_poller_treats_gateway_error_as_transient(): + poller = _poller_with_result({"error": "Gateway 500", "status": 500}) + result = await poller._check_transaction_status("solana", "mainnet-beta", "TX") + assert result is None # retried later — NOT recorded as FAILED + + +@pytest.mark.asyncio +async def test_poller_still_reports_real_onchain_failure(): + poller = _poller_with_result({ + "txStatus": -1, + "error": "SLIPPAGE_EXCEEDED (0x1771): slippage tolerance exceeded", + "fee": 0.00001, + }) + result = await poller._check_transaction_status("solana", "mainnet-beta", "TX") + assert result["status"] == "FAILED" + assert "SLIPPAGE_EXCEEDED" in result["error_message"] + + +@pytest.mark.asyncio +async def test_poller_confirms_transaction(): + poller = _poller_with_result({"txStatus": 1, "fee": 0.00001, "error": None}) + result = await poller._check_transaction_status("solana", "mainnet-beta", "TX") + assert result["status"] == "CONFIRMED" diff --git a/test/test_portfolio_state.py b/test/test_portfolio_state.py index 51234cec..6f590aa0 100644 --- a/test/test_portfolio_state.py +++ b/test/test_portfolio_state.py @@ -95,3 +95,64 @@ async def test_balance_failure_preserves_stale_data(self, accounts_service, mock assert len(result) == 1 assert result[0]["token"] == "USDT" assert result[0]["units"] == 500.0 + + +class TestGatewayWalletState: + """Gateway wallet balances must not be overwritten by the Gateway connector's own view.""" + + @pytest.fixture + def accounts_service(self): + from services.accounts_service import AccountsService + + service = AccountsService.__new__(AccountsService) + service.accounts_state = {} + service._connector_service = MagicMock() + return service + + @pytest.mark.asyncio + async def test_gateway_connector_does_not_overwrite_wallet_balances(self, accounts_service): + """A Gateway connector must not replace the full wallet balances with its own tokens.""" + wallet_balances = [ + {"token": "SOL", "units": 0.35, "price": 0.0, "value": 0.0, "available_units": 0.35}, + {"token": "USDC", "units": 1.31, "price": 1.0, "value": 1.31, "available_units": 1.31}, + {"token": "TRUMP", "units": 0.15, "price": 0.0, "value": 0.0, "available_units": 0.15}, + ] + + gateway_connector = MagicMock() + accounts_service._connector_service.get_all_trading_connectors.return_value = { + "master_account": {"solana-mainnet-beta": gateway_connector} + } + accounts_service._connector_service.is_gateway_connector.return_value = True + + async def fake_gateway_update(chain_networks=None): + accounts_service.accounts_state["master_account"]["solana-mainnet-beta"] = wallet_balances + + accounts_service._update_gateway_balances = fake_gateway_update + accounts_service._get_connector_tokens_info = AsyncMock( + return_value=[{"token": "SOL", "units": 0.35}] + ) + + await accounts_service.update_account_state(connector_names=["solana-mainnet-beta"]) + + accounts_service._get_connector_tokens_info.assert_not_called() + assert accounts_service.accounts_state["master_account"]["solana-mainnet-beta"] == wallet_balances + + @pytest.mark.asyncio + async def test_gateway_state_mirrored_to_other_accounts(self, accounts_service): + """Non-master accounts with a Gateway connector get the same wallet balances.""" + wallet_balances = [{"token": "TRUMP", "units": 0.15, "price": 0.0, "value": 0.0}] + + accounts_service._connector_service.get_all_trading_connectors.return_value = { + "trader_account": {"solana-mainnet-beta": MagicMock()} + } + accounts_service._connector_service.is_gateway_connector.return_value = True + + async def fake_gateway_update(chain_networks=None): + accounts_service.accounts_state.setdefault("master_account", {}) + accounts_service.accounts_state["master_account"]["solana-mainnet-beta"] = wallet_balances + + accounts_service._update_gateway_balances = fake_gateway_update + + await accounts_service.update_account_state() + + assert accounts_service.accounts_state["trader_account"]["solana-mainnet-beta"] == wallet_balances