From f1877a4c67be405940fe5ea5ba38c78b94263446 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Mon, 13 Jul 2026 22:09:35 -0700 Subject: [PATCH 1/4] fix(gateway): use unified /trading endpoints and stop masking Gateway errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GatewayClient: - swaps go through Gateway's unified /trading/swap/{quote,execute} instead of /connectors/{c}/router/*, which 404'd for clmm-only connectors (meteora, orca) and doubled the path for typed values like "jupiter/router" - normalize_swap_connector(): bare router connectors (jupiter, 0x, uniswap, pancakeswap) get /router appended, everything else /clmm; typed values pass through - CLMM ops go through unified /trading/clmm/* with the schema Gateway actually expects (chainNetwork, walletAddress, percentageToRemove) - add GatewayError + check_gateway_error() for the {"error","status"} dict _request returns on non-OK responses Callers: - /gateway/swap/quote no longer renders a Gateway 404 as a 200 quote with price "0" — Gateway errors propagate with their real status code - _refresh_position_data no longer closes DB positions when positions-owned returns a Gateway error - transaction poller treats Gateway HTTP errors as transient instead of recording them as on-chain FAILED - gecko price source no longer caches an empty token map on Gateway errors - slippage_pct=0 was dropped as falsy everywhere; use `is not None` Adds contract tests pinning paths/payload keys and error propagation. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Ko4KsGwUaHjqiRTYon31Li --- routers/gateway.py | 90 ++++++---- routers/gateway_clmm.py | 179 +++++++------------ routers/gateway_swap.py | 54 +++--- services/accounts_service.py | 5 + services/gateway_client.py | 197 +++++++++++++------- services/gateway_transaction_poller.py | 9 + services/gateway_wallet_service.py | 4 +- services/gecko_price_source.py | 10 +- test/test_gateway_client_contract.py | 238 +++++++++++++++++++++++++ test/test_gateway_error_masking.py | 164 +++++++++++++++++ 10 files changed, 699 insertions(+), 251 deletions(-) create mode 100644 test/test_gateway_client_contract.py create mode 100644 test/test_gateway_error_masking.py 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..fdf1efb3 100644 --- a/services/accounts_service.py +++ b/services/accounts_service.py @@ -1111,6 +1111,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_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/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" From e1dd9afb85fd93cb3149d1e51b2e8b6de0ea11dd Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Fri, 17 Jul 2026 17:05:55 -0700 Subject: [PATCH 2/4] fix(gateway): start the Gateway status monitor so networks register in AllConnectorSettings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gateway network connectors (e.g. 'solana-mainnet-beta') are only enumerated into hummingbot's AllConnectorSettings by GatewayHttpClient's status monitor, which reads /config/chains when Gateway comes online. That monitor is started by the standalone client's TradingCore but was never started here, so Gateway networks were absent from AllConnectorSettings until a connector for one happened to be constructed on demand. Start the monitor in the app lifespan (and stop it on shutdown). Newly added chains (e.g. unichain, robinhoodchain) now enumerate without first deploying a bot on them, and the monitor picks up Gateway coming online later. Startup is unaffected when Gateway is unreachable — the monitor pings in the background and simply stays offline. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Lur3b8AWCGzTQq3cG6kjxr --- main.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/main.py b/main.py index 17877fc7..27497c70 100644 --- a/main.py +++ b/main.py @@ -125,7 +125,14 @@ async def lifespan(app: FastAPI): 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. + gateway_client.start_monitor() logging.info(f"Initialized GatewayHttpClient with URL: {settings.gateway.url}") # Initialize secrets manager and database @@ -309,6 +316,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() From 87580598a137e917e8064ec6b82240021b8abe49 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Mon, 20 Jul 2026 07:01:52 -0700 Subject: [PATCH 3/4] fix(gateway): initialize Security before the status monitor and defer it until mTLS certs exist On a fresh install the monitor started by e1dd9af pinged Gateway every 2s before anything had populated Security.secrets_manager (it was only set lazily on first connector creation), so each ping crashed building the mTLS client with "AttributeError: 'NoneType' object has no attribute 'password'". - Log master_account in during lifespan startup, before the GatewayHttpClient singleton is created, so Security.secrets_manager is always set (and a wrong CONFIG_PASSWORD now fails startup with a clear error instead of surfacing later as decrypt failures). - When Gateway is secured (https) but the shared mTLS certs haven't been generated yet (fresh install, Gateway never started), defer the monitor instead of letting every ping fail loudly on the missing cert files. GatewayService.start() starts it right after generating the certs (start_monitor is a no-op when already running). Co-Authored-By: Claude Fable 5 --- main.py | 27 +++++++++++++++++++++++---- services/gateway_service.py | 11 +++++++++-- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/main.py b/main.py index 27497c70..f05dbab7 100644 --- a/main.py +++ b/main.py @@ -111,14 +111,26 @@ 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", @@ -132,11 +144,18 @@ async def lifespan(app: FastAPI): # 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. - gateway_client.start_monitor() + # 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") 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 } From c7f028c3606762fb7a5b722cbc71548daf58e1b1 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 21 Jul 2026 07:51:01 -0700 Subject: [PATCH 4/4] fix(portfolio): stop Gateway connectors from overwriting wallet balances A Gateway network such as 'solana-mainnet-beta' can exist twice in the account-state pipeline: as a unified Gateway trading connector and as a Gateway wallet fetched by _update_gateway_balances. Both wrote the same accounts_state[account]['solana-mainnet-beta'] key, and since connector results are applied after the gather, the connector view always won. That view is much narrower: gateway_base.update_balances() only queries Gateway for the tokens of the connector's trading pairs plus the native currency, so wallet tokens the connector doesn't trade disappear from portfolio state, and mint-address trading pairs report mint addresses instead of symbols. Skip Gateway connectors in the per-connector balance pass so the full wallet snapshot is the single source of truth for those keys, and mirror it onto non-master accounts holding the same connector (they trade the same Gateway default wallet) so their state doesn't go stale. Verified against a live Gateway: portfolio state now returns all seven wallet tokens with symbols, matching POST /chains/solana/balances; on the previous code the same setup returned four. Fixes #195 Co-Authored-By: Claude Opus 4.8 (1M context) --- services/accounts_service.py | 37 ++++++++++++++++ services/unified_connector_service.py | 11 +++++ test/test_portfolio_state.py | 61 +++++++++++++++++++++++++++ 3 files changed, 109 insertions(+) diff --git a/services/accounts_service.py b/services/accounts_service.py index fdf1efb3..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. 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_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