From 42ae18be404dc699a965a991904a25bf3f30f4ec Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 28 Apr 2026 00:50:29 -0700 Subject: [PATCH 1/5] feat(gateway): update pool API to use chain+network organization - Update gateway_client to use chain+network as primary keys for pool operations - get_pools: add chain parameter, connector becomes optional filter - add_pool: add chain parameter - remove_pool: change from connector to chain parameter - Add new network-based pool endpoints: GET/POST/DELETE /networks/{network_id}/pools - Deprecate legacy /pools endpoints (still functional for backward compatibility) - Update models to reflect new API structure This aligns with Gateway API changes that organize pools by network instead of by DEX connector. Co-Authored-By: Claude Opus 4.5 --- models/gateway.py | 10 +- routers/gateway.py | 364 ++++++++++++++++++++++++++----------- services/gateway_client.py | 41 ++++- 3 files changed, 292 insertions(+), 123 deletions(-) diff --git a/models/gateway.py b/models/gateway.py index 849acd4c..29ba1476 100644 --- a/models/gateway.py +++ b/models/gateway.py @@ -1,11 +1,12 @@ -from pydantic import BaseModel, Field -from typing import Optional, List +from typing import List, Optional +from pydantic import BaseModel, Field # ============================================ # Container Management Models # ============================================ + class GatewayConfig(BaseModel): """Configuration for Gateway container deployment""" passphrase: str = Field(description="Gateway passphrase for configuration encryption") @@ -77,7 +78,10 @@ class AddPoolRequest(BaseModel): """Request to add a liquidity pool""" connector_name: str = Field(description="DEX connector name (e.g., 'raydium', 'meteora')") type: str = Field(description="Pool type ('clmm' or 'amm')") - network: str = Field(description="Network name (e.g., 'mainnet-beta')") + network: Optional[str] = Field( + default=None, + description="Network name (e.g., 'mainnet-beta') - optional for /networks/{network_id}/pools" + ) address: str = Field(description="Pool contract address") base: str = Field(description="Base token symbol") quote: str = Field(description="Quote token symbol") diff --git a/routers/gateway.py b/routers/gateway.py index 6c599b87..aa32fa20 100644 --- a/routers/gateway.py +++ b/routers/gateway.py @@ -234,7 +234,7 @@ async def update_connector_config( return { "success": True, - "message": f"Updated {len(results)} config parameter(s) for {connector_name}. Restart Gateway for changes to take effect.", + "message": f"Updated {len(results)} config param(s) for {connector_name}. Restart Gateway.", "restart_required": True, "restart_endpoint": "POST /gateway/restart", "results": results @@ -271,28 +271,32 @@ async def list_chains(accounts_service: AccountsService = Depends(get_accounts_s # ============================================ -# Pools +# Pools (Legacy - use /networks/{network_id}/pools instead) # ============================================ -@router.get("/pools") -async def list_pools( +@router.get("/pools", deprecated=True) +async def list_pools_legacy( connector_name: str = Query(description="DEX connector (e.g., 'meteora', 'raydium')"), network: str = Query(description="Network (e.g., 'mainnet-beta')"), accounts_service: AccountsService = Depends(get_accounts_service) ) -> List[Dict]: """ - List all liquidity pools for a connector and network. + [DEPRECATED] Use GET /gateway/networks/{network_id}/pools instead. - Returns normalized data with snake_case fields and trading_pair. + List all liquidity pools for a connector and network. """ try: if not await accounts_service.gateway_client.ping(): raise HTTPException(status_code=503, detail="Gateway service is not available") - pools = await accounts_service.gateway_client.get_pools(connector_name, network) + # Determine chain from connector (legacy behavior) + # 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) if not pools: - raise HTTPException(status_code=400, detail=f"No pools found for {connector_name}/{network}") + return [] # Normalize each pool normalized_pools = [normalize_gateway_response(pool) for pool in pools] @@ -304,102 +308,6 @@ async def list_pools( raise HTTPException(status_code=500, detail=f"Error getting pools: {str(e)}") -@router.post("/pools") -async def add_pool( - pool_request: AddPoolRequest, - accounts_service: AccountsService = Depends(get_accounts_service) -) -> Dict: - """ - Add a custom liquidity pool. - - Args: - pool_request: Pool details (connector, type, network, base, quote, address) - """ - try: - 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.add_pool( - connector=pool_request.connector_name, - pool_type=pool_request.type, - network=pool_request.network, - address=pool_request.address, - base_symbol=pool_request.base, - quote_symbol=pool_request.quote, - base_token_address=pool_request.base_address, - quote_token_address=pool_request.quote_address, - fee_pct=pool_request.fee_pct - ) - - if result is None: - raise HTTPException(status_code=502, detail="Failed to add pool: Gateway returned no response") - - if "error" in result: - status = result.get("status", 400) - raise HTTPException(status_code=status, detail=f"Failed to add pool: {result.get('error')}") - - trading_pair = f"{pool_request.base}-{pool_request.quote}" - return { - "message": f"Pool {trading_pair} added to {pool_request.connector_name}/{pool_request.network}", - "trading_pair": trading_pair - } - - except HTTPException: - raise - except Exception as e: - raise HTTPException(status_code=500, detail=f"Error adding pool: {str(e)}") - - -@router.delete("/pools/{address}") -async def delete_pool( - address: str, - connector_name: str = Query(description="DEX connector (e.g., 'meteora', 'raydium', 'uniswap')"), - network: str = Query(description="Network name (e.g., 'mainnet-beta', 'mainnet')"), - pool_type: str = Query(description="Pool type (e.g., 'clmm', 'amm')"), - accounts_service: AccountsService = Depends(get_accounts_service) -) -> Dict: - """ - Delete a liquidity pool from Gateway's pool list. - - Args: - address: Pool contract address to remove - connector_name: DEX connector (e.g., 'meteora', 'raydium', 'uniswap') - network: Network name (e.g., 'mainnet-beta', 'mainnet') - pool_type: Pool type (e.g., 'clmm', 'amm') - - Example: DELETE /gateway/pools/2sf5NYcY...?connector_name=meteora&network=mainnet-beta&pool_type=clmm - """ - try: - 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.delete_pool( - connector=connector_name, - network=network, - pool_type=pool_type, - address=address - ) - - if result is None: - raise HTTPException(status_code=400, detail="Failed to delete pool - no response from Gateway") - - if "error" in result: - raise HTTPException(status_code=400, detail=f"Failed to delete pool: {result.get('error')}") - - return { - "success": True, - "message": f"Pool {address} deleted from {connector_name}/{network}", - "pool_address": address, - "connector": connector_name, - "network": network - } - - except HTTPException: - raise - except Exception as e: - raise HTTPException(status_code=500, detail=f"Error deleting pool: {str(e)}") - - # ============================================ # Networks (Primary Endpoints) # ============================================ @@ -499,7 +407,7 @@ async def update_network_config( return { "success": True, - "message": f"Updated {len(results)} config parameter(s) for {network_id}. Restart Gateway for changes to take effect.", + "message": f"Updated {len(results)} config parameter(s) for {network_id}. Restart Gateway.", "restart_required": True, "restart_endpoint": "POST /gateway/restart", "results": results @@ -533,7 +441,7 @@ async def get_network_tokens( # Parse network_id into chain and network parts = network_id.split('-', 1) if len(parts) != 2: - raise HTTPException(status_code=400, detail=f"Invalid network_id format. Expected 'chain-network', got '{network_id}'") + 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) @@ -543,8 +451,8 @@ async def get_network_tokens( search_lower = search.lower() result["tokens"] = [ token for token in result["tokens"] - if search_lower in token.get("symbol", "").lower() or - search_lower in token.get("name", "").lower() + if (search_lower in token.get("symbol", "").lower() or + search_lower in token.get("name", "").lower()) ] return result @@ -585,7 +493,7 @@ async def add_network_token( # Parse network_id into chain and network parts = network_id.split('-', 1) if len(parts) != 2: - raise HTTPException(status_code=400, detail=f"Invalid network_id format. Expected 'chain-network', got '{network_id}'") + raise HTTPException(status_code=400, detail=f"Invalid network_id format: '{network_id}'. Use 'chain-network'") chain, network = parts @@ -644,7 +552,7 @@ async def delete_network_token( # Parse network_id into chain and network parts = network_id.split('-', 1) if len(parts) != 2: - raise HTTPException(status_code=400, detail=f"Invalid network_id format. Expected 'chain-network', got '{network_id}'") + raise HTTPException(status_code=400, detail=f"Invalid network_id format: '{network_id}'. Use 'chain-network'") chain, network = parts @@ -670,6 +578,242 @@ async def delete_network_token( raise HTTPException(status_code=500, detail=f"Error deleting token: {str(e)}") +# ============================================ +# Network Pools +# ============================================ + +@router.get("/networks/{network_id}/pools") +async def get_network_pools( + network_id: str, + connector: Optional[str] = Query(default=None, description="Filter by connector (e.g., 'raydium', 'meteora')"), + pool_type: Optional[str] = Query(default=None, description="Filter by type ('amm' or 'clmm')"), + search: Optional[str] = Query(default=None, description="Search by trading pair or address"), + accounts_service: AccountsService = Depends(get_accounts_service) +) -> Dict: + """ + Get available pools for a network. + + Args: + network_id: Network ID in format 'chain-network' (e.g., 'solana-mainnet-beta') + connector: Optional filter by connector (e.g., 'raydium', 'meteora', 'uniswap') + pool_type: Optional filter by type ('amm' or 'clmm') + search: Optional search by trading pair (e.g., 'SOL-USDC') or pool address + + Example: GET /gateway/networks/solana-mainnet-beta/pools?connector=raydium&type=clmm + """ + try: + if not await accounts_service.gateway_client.ping(): + raise HTTPException(status_code=503, detail="Gateway service is not available") + + # Parse network_id into chain and network + parts = network_id.split('-', 1) + if len(parts) != 2: + 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( + 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 [] + + return { + "pools": normalized_pools, + "count": len(normalized_pools), + "network_id": network_id + } + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error getting network pools: {str(e)}") + + +@router.post("/networks/{network_id}/pools") +async def add_network_pool( + network_id: str, + pool_request: AddPoolRequest, + accounts_service: AccountsService = Depends(get_accounts_service) +) -> Dict: + """ + Add a custom pool to Gateway's pool list for a specific network. + + Args: + network_id: Network ID in format 'chain-network' (e.g., 'solana-mainnet-beta', 'ethereum-mainnet') + pool_request: Pool details (connector, type, base, quote, address, etc.) + + Example: POST /gateway/networks/solana-mainnet-beta/pools + { + "connector_name": "raydium", + "type": "clmm", + "base": "SOL", + "quote": "USDC", + "address": "58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2", + "base_address": "So11111111111111111111111111111111111111112", + "quote_address": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "fee_pct": 0.25 + } + + Note: After adding a pool, restart Gateway for changes to take effect. + """ + try: + if not await accounts_service.gateway_client.ping(): + raise HTTPException(status_code=503, detail="Gateway service is not available") + + # Parse network_id into chain and network + parts = network_id.split('-', 1) + if len(parts) != 2: + 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.add_pool( + chain=chain, + network=network, + connector=pool_request.connector_name, + pool_type=pool_request.type, + address=pool_request.address, + base_symbol=pool_request.base, + quote_symbol=pool_request.quote, + base_token_address=pool_request.base_address, + quote_token_address=pool_request.quote_address, + fee_pct=pool_request.fee_pct + ) + + if result is None: + raise HTTPException(status_code=502, detail="Failed to add pool: Gateway returned no response") + + if "error" in result: + status = result.get("status", 400) + raise HTTPException(status_code=status, detail=f"Failed to add pool: {result.get('error')}") + + trading_pair = f"{pool_request.base}-{pool_request.quote}" + return { + "success": True, + "message": f"Pool {trading_pair} added to {network_id}.", + "pool": { + "trading_pair": trading_pair, + "connector": pool_request.connector_name, + "type": pool_request.type, + "address": pool_request.address, + "network_id": network_id + } + } + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error adding pool: {str(e)}") + + +@router.post("/networks/{network_id}/pools/save/{pool_address}") +async def save_network_pool( + network_id: str, + pool_address: str, + accounts_service: AccountsService = Depends(get_accounts_service) +) -> Dict: + """ + Save a pool by address using GeckoTerminal lookup. + This automatically fetches pool info and token info from GeckoTerminal. + + Args: + network_id: Network ID in format 'chain-network' (e.g., 'solana-mainnet-beta') + pool_address: Pool contract address + + Example: POST /gateway/networks/solana-mainnet-beta/pools/save/58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2 + + Note: This will auto-add any missing tokens to the network's token list. + """ + try: + 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.save_pool( + chain_network=network_id, + address=pool_address + ) + + if result is None: + raise HTTPException(status_code=502, detail="Failed to save pool: Gateway returned no response") + + if "error" in result: + raise HTTPException(status_code=400, detail=f"Failed to save pool: {result.get('error')}") + + pool = result.get("pool", {}) + tokens_added = result.get("tokensAdded", []) + + return { + "success": True, + "message": result.get("message", f"Pool saved to {network_id}"), + "pool": normalize_gateway_response(pool), + "tokens_added": tokens_added, + "network_id": network_id + } + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error saving pool: {str(e)}") + + +@router.delete("/networks/{network_id}/pools/{pool_address}") +async def delete_network_pool( + network_id: str, + pool_address: str, + accounts_service: AccountsService = Depends(get_accounts_service) +) -> Dict: + """ + Delete a pool from Gateway's pool list for a specific network. + + Args: + network_id: Network ID in format 'chain-network' (e.g., 'solana-mainnet-beta', 'ethereum-mainnet') + pool_address: Pool contract address to delete + + Example: DELETE /gateway/networks/solana-mainnet-beta/pools/58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2 + + Note: After deleting a pool, restart Gateway for changes to take effect. + """ + try: + if not await accounts_service.gateway_client.ping(): + raise HTTPException(status_code=503, detail="Gateway service is not available") + + # Parse network_id into chain and network + parts = network_id.split('-', 1) + if len(parts) != 2: + 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.delete_pool( + chain=chain, + network=network, + address=pool_address + ) + + if result is None: + raise HTTPException(status_code=400, detail="Failed to delete pool - no response from Gateway") + + if "error" in result: + raise HTTPException(status_code=400, detail=f"Failed to delete pool: {result.get('error')}") + + return { + "success": True, + "message": f"Pool {pool_address} deleted from {network_id}.", + "pool_address": pool_address, + "network_id": network_id + } + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error deleting pool: {str(e)}") + + # ============================================ # Wallet Management # ============================================ diff --git a/services/gateway_client.py b/services/gateway_client.py index 9a92a19b..2769e75f 100644 --- a/services/gateway_client.py +++ b/services/gateway_client.py @@ -267,18 +267,33 @@ async def update_config(self, namespace: str, path: str, value: any) -> Dict: "value": value }) - async def get_pools(self, connector: str, network: str) -> List[Dict]: - """Get pools for a connector and network""" - return await self._request("GET", "pools", params={ - "connector": connector, + async def get_pools( + self, + chain: str, + network: str, + connector: Optional[str] = None, + pool_type: Optional[str] = None, + search: Optional[str] = None + ) -> List[Dict]: + """Get pools for a chain and network with optional filtering""" + params = { + "chain": chain, "network": network - }) + } + if connector: + params["connector"] = connector + if pool_type: + params["type"] = pool_type.lower() + if search: + params["search"] = search + return await self._request("GET", "pools", params=params) async def add_pool( self, + chain: str, + network: str, connector: str, pool_type: str, - network: str, address: str, base_symbol: str, quote_symbol: str, @@ -288,6 +303,7 @@ async def add_pool( ) -> Dict: """Add a new pool""" payload = { + "chain": chain, "connector": connector, "type": pool_type.lower(), # Gateway expects lowercase (amm, clmm) "network": network, @@ -301,12 +317,17 @@ async def add_pool( payload["feePct"] = fee_pct return await self._request("POST", "pools", json=payload) - async def delete_pool(self, connector: str, network: str, pool_type: str, address: str) -> Dict: + async def save_pool(self, chain_network: str, address: str) -> Dict: + """Save a pool by address using GeckoTerminal lookup""" + return await self._request("POST", f"pools/save/{address}", params={ + "chainNetwork": chain_network + }) + + async def delete_pool(self, chain: str, network: str, address: str) -> Dict: """Delete a pool from Gateway's pool list""" return await self._request("DELETE", f"pools/{address}", params={ - "connector": connector, - "network": network, - "type": pool_type.lower() # Gateway expects lowercase (amm, clmm) + "chain": chain, + "network": network }) async def pool_info(self, connector: str, network: str, pool_address: str) -> Dict: From d501e46a9e88de93d16ef04cecd7f98e0d1f8a0c Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 28 Apr 2026 17:29:14 -0700 Subject: [PATCH 2/5] feat(clmm): update pool listing to use Gateway fetch-pools - Simplify CLMMPoolListItem to match Gateway response format - Update CLMMPoolListResponse to use pageSize instead of limit - Add gateway_base_url to AccountsService - Remove Query aliases to match client parameter names - Remove is_verified field (not available from Gateway) Co-Authored-By: Claude Opus 4.5 --- models/gateway_trading.py | 49 ++------ routers/gateway_clmm.py | 222 +++++++++++++++-------------------- services/accounts_service.py | 1 + 3 files changed, 105 insertions(+), 167 deletions(-) diff --git a/models/gateway_trading.py b/models/gateway_trading.py index 02d7c422..bef49e1b 100644 --- a/models/gateway_trading.py +++ b/models/gateway_trading.py @@ -4,10 +4,10 @@ Note: AMM support has been removed. Use Router for simple swaps, CLMM for liquidity provision. """ -from typing import Optional, List, Dict, Any -from pydantic import BaseModel, Field from decimal import Decimal +from typing import Any, Dict, List, Optional +from pydantic import BaseModel, Field # ============================================ # Swap Models (Router: Jupiter, 0x) @@ -278,58 +278,25 @@ class TimeBasedMetrics(BaseModel): class CLMMPoolListItem(BaseModel): - """Individual pool item in CLMM pool listing""" + """Individual pool item in CLMM pool listing - matches Gateway fetch-pools response""" address: str = Field(description="Pool address") name: str = Field(description="Pool name (e.g., 'SOL-USDC')") trading_pair: str = Field(description="Trading pair derived from tokens") mint_x: str = Field(description="Base token mint address") mint_y: str = Field(description="Quote token mint address") - bin_step: int = Field(description="Bin step size") + bin_step: int = Field(description="Bin step / tick spacing") current_price: Decimal = Field(description="Current pool price") - liquidity: str = Field(description="Total liquidity in pool") - reserve_x: str = Field(description="Base token reserves") - reserve_y: str = Field(description="Quote token reserves") - reserve_x_amount: Optional[Decimal] = Field(default=None, description="Base token reserves as decimal amount") - reserve_y_amount: Optional[Decimal] = Field(default=None, description="Quote token reserves as decimal amount") - - # Fee structure + liquidity: str = Field(description="Total value locked (TVL) in USD") base_fee_percentage: Optional[str] = Field(default=None, description="Base fee percentage") - max_fee_percentage: Optional[str] = Field(default=None, description="Maximum fee percentage") - protocol_fee_percentage: Optional[str] = Field(default=None, description="Protocol fee percentage") - - # APR/APY apr: Optional[Decimal] = Field(default=None, description="Annual percentage rate") apy: Optional[Decimal] = Field(default=None, description="Annual percentage yield") - farm_apr: Optional[Decimal] = Field(default=None, description="Farming annual percentage rate") - farm_apy: Optional[Decimal] = Field(default=None, description="Farming annual percentage yield") - - # Volume and fees volume_24h: Optional[Decimal] = Field(default=None, description="24h trading volume") fees_24h: Optional[Decimal] = Field(default=None, description="24h fees collected") - today_fees: Optional[Decimal] = Field(default=None, description="Today's fees collected") - cumulative_trade_volume: Optional[str] = Field(default=None, description="Cumulative trade volume") - cumulative_fee_volume: Optional[str] = Field(default=None, description="Cumulative fee volume") - - # Time-based metrics - volume: Optional[TimeBasedMetrics] = Field(default=None, description="Volume across different time periods") - fees: Optional[TimeBasedMetrics] = Field(default=None, description="Fees across different time periods") - fee_tvl_ratio: Optional[TimeBasedMetrics] = Field(default=None, description="Fee-to-TVL ratio across different time periods") - - # Rewards - reward_mint_x: Optional[str] = Field(default=None, description="Base token reward mint address") - reward_mint_y: Optional[str] = Field(default=None, description="Quote token reward mint address") - - # Metadata - tags: Optional[List[str]] = Field(default=None, description="Pool tags") - is_verified: bool = Field(default=False, description="Whether tokens are verified") - is_blacklisted: Optional[bool] = Field(default=None, description="Whether pool is blacklisted") - hide: Optional[bool] = Field(default=None, description="Whether pool should be hidden") - launchpad: Optional[str] = Field(default=None, description="Associated launchpad") class CLMMPoolListResponse(BaseModel): - """Response with list of available CLMM pools""" + """Response with list of available CLMM pools - matches Gateway fetch-pools response""" pools: List[CLMMPoolListItem] = Field(description="List of available pools") - total: int = Field(description="Total number of pools") + total: int = Field(description="Total number of matching pools") page: int = Field(description="Current page number") - limit: int = Field(description="Results per page") + pageSize: int = Field(description="Number of pools per page") diff --git a/routers/gateway_clmm.py b/routers/gateway_clmm.py index cb23e9d9..18554aeb 100644 --- a/routers/gateway_clmm.py +++ b/routers/gateway_clmm.py @@ -4,73 +4,78 @@ """ import asyncio import logging -from typing import List, Optional from decimal import Decimal -import aiohttp +from typing import List, Optional +import aiohttp from fastapi import APIRouter, Depends, HTTPException, Query -from deps import get_accounts_service, get_database_manager -from services.accounts_service import AccountsService from database import AsyncDatabaseManager from database.repositories import GatewayCLMMRepository +from deps import get_accounts_service, get_database_manager from models import ( - CLMMOpenPositionRequest, - CLMMOpenPositionResponse, CLMMAddLiquidityRequest, - CLMMRemoveLiquidityRequest, CLMMClosePositionRequest, CLMMCollectFeesRequest, CLMMCollectFeesResponse, - CLMMPositionsOwnedRequest, - CLMMPositionInfo, + CLMMOpenPositionRequest, + CLMMOpenPositionResponse, CLMMPoolInfoResponse, CLMMPoolListItem, CLMMPoolListResponse, + CLMMPositionInfo, + CLMMPositionsOwnedRequest, + CLMMRemoveLiquidityRequest, TimeBasedMetrics, ) +from services.accounts_service import AccountsService logger = logging.getLogger(__name__) router = APIRouter(tags=["Gateway CLMM"], prefix="/gateway") -async def fetch_meteora_pools( +async def fetch_pools_from_gateway( + gateway_url: str, + connector: str, + network: str = "mainnet-beta", page: int = 0, limit: int = 50, - search_term: Optional[str] = None, - sort_key: Optional[str] = "volume", - order_by: Optional[str] = "desc", - include_unknown: bool = True + query: Optional[str] = None, + sort_by: Optional[str] = None, + include_unverified: bool = True ) -> Optional[dict]: """ - Fetch available pools from Meteora API. + Fetch pools from Gateway's fetch-pools endpoint. Args: - page: Page number (default: 0) - limit: Results per page (default: 50) - search_term: Search term to filter pools - sort_key: Sort key (tvl, volume, feetvlratio, etc.) - order_by: Sort order (asc, desc) - include_unknown: Include pools with unverified tokens + 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 Meteora API, or None if failed + Dictionary with pools from Gateway, or None if failed """ try: - url = "https://dlmm-api.meteora.ag/pair/all_by_groups" + url = f"{gateway_url}/connectors/{connector}/clmm/fetch-pools" params = { - "page": page, + "network": network, "limit": limit, - "include_unknown": str(include_unknown).lower() # Convert boolean to lowercase string } - if search_term: - params["search_term"] = search_term - if sort_key: - params["sort_key"] = sort_key - if order_by: - params["order_by"] = order_by + 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: @@ -78,10 +83,10 @@ async def fetch_meteora_pools( data = await response.json() return data except aiohttp.ClientError as e: - logger.error(f"Failed to fetch pools from Meteora API: {e}") + logger.error(f"Failed to fetch pools from Gateway: {e}") return None except Exception as e: - logger.error(f"Error fetching Meteora pools: {e}", exc_info=True) + logger.error(f"Error fetching pools from Gateway: {e}", exc_info=True) return None @@ -397,133 +402,98 @@ async def get_clmm_pools( connector: str, page: int = Query(0, ge=0, description="Page number"), limit: int = Query(50, ge=1, le=100, description="Results per page (max 100)"), - search_term: Optional[str] = Query(None, description="Search term to filter pools"), + search_term: Optional[str] = Query(None, description="Search query to filter pools"), sort_key: Optional[str] = Query("volume", description="Sort key (volume, tvl, etc.)"), order_by: Optional[str] = Query("desc", description="Sort order (asc, desc)"), - include_unknown: bool = Query(True, description="Include pools with unverified tokens") + include_unknown: bool = Query(True, description="Include pools with unverified tokens"), + accounts_service: AccountsService = Depends(get_accounts_service) ): """ - Get list of available CLMM pools for a connector. + Get list of available CLMM pools for a connector via Gateway. - Currently supports: meteora + Supports: meteora, orca Args: - connector: CLMM connector (e.g., 'meteora') + connector: CLMM connector (meteora, orca) page: Page number (default: 0) limit: Results per page (default: 50, max: 100) - search_term: Search term to filter pools (optional) - sort_key: Sort by field (volume, tvl, feetvlratio, etc.) + search_term: Search query to filter pools (optional) + sort_key: Sort by field (volume, tvl, etc.) order_by: Sort order (asc, desc) include_unknown: Include pools with unverified tokens Example: - GET /gateway/clmm/pools?connector=meteora&search_term=SOL&limit=20 + GET /gateway/clmm/pools?connector=meteora&query=SOL&limit=20 Returns: List of available pools with trading pairs, addresses, liquidity, volume, APR, etc. """ try: - # Only support Meteora for now - if connector.lower() != "meteora": + supported_connectors = ["meteora", "orca"] + if connector.lower() not in supported_connectors: raise HTTPException( status_code=400, - detail=f"Pool listing not supported for connector '{connector}'. Currently only 'meteora' is supported." + detail=f"Pool listing not supported for connector '{connector}'. Supported: {', '.join(supported_connectors)}" ) - # Fetch pools from Meteora API - logger.info(f"Fetching pools from Meteora API (page={page}, limit={limit}, search={search_term})") - meteora_data = await fetch_meteora_pools( + # 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) + sort_by = None + if sort_key: + if connector.lower() == "meteora": + time_suffix = "_24h" if sort_key in ["volume", "fees"] else "" + direction = order_by if order_by else "desc" + sort_by = f"{sort_key}{time_suffix}:{direction}" + else: # orca + sort_by = sort_key + + gateway_data = await fetch_pools_from_gateway( + gateway_url=gateway_url, + connector=connector.lower(), page=page, limit=limit, - search_term=search_term, - sort_key=sort_key, - order_by=order_by, - include_unknown=include_unknown + query=search_term, + sort_by=sort_by, + include_unverified=include_unknown ) - if meteora_data is None: - raise HTTPException(status_code=503, detail="Failed to fetch pools from Meteora API") + if gateway_data is None: + raise HTTPException(status_code=503, detail=f"Failed to fetch pools from Gateway for {connector}") - # Transform Meteora response to our format + # Transform Gateway response to our format + # Both Meteora and Orca now return same format: {pools: [...], total, page, pageSize} pools = [] - groups = meteora_data.get("groups", []) - - for group in groups: - pairs = group.get("pairs", []) - for pair in pairs: - # Extract trading pair from name or construct from mints - name = pair.get("name", "") - trading_pair = name if name else f"{pair.get('mint_x', '')[:8]}-{pair.get('mint_y', '')[:8]}" - - # Helper function to safely convert dict metrics to TimeBasedMetrics - def to_time_metrics(data): - if not data: - return None - return TimeBasedMetrics( - min_30=Decimal(str(data.get("min_30"))) if data.get("min_30") is not None else None, - hour_1=Decimal(str(data.get("hour_1"))) if data.get("hour_1") is not None else None, - hour_2=Decimal(str(data.get("hour_2"))) if data.get("hour_2") is not None else None, - hour_4=Decimal(str(data.get("hour_4"))) if data.get("hour_4") is not None else None, - hour_12=Decimal(str(data.get("hour_12"))) if data.get("hour_12") is not None else None, - hour_24=Decimal(str(data.get("hour_24"))) if data.get("hour_24") is not None else None - ) + pool_list = gateway_data.get("pools", []) - pools.append(CLMMPoolListItem( - address=pair.get("address", ""), - name=name, - trading_pair=trading_pair, - mint_x=pair.get("mint_x", ""), - mint_y=pair.get("mint_y", ""), - bin_step=pair.get("bin_step", 0), - current_price=Decimal(str(pair.get("current_price", 0))), - liquidity=pair.get("liquidity", "0"), - reserve_x=pair.get("reserve_x", "0"), - reserve_y=pair.get("reserve_y", "0"), - reserve_x_amount=Decimal(str(pair.get("reserve_x_amount"))) if pair.get("reserve_x_amount") is not None else None, - reserve_y_amount=Decimal(str(pair.get("reserve_y_amount"))) if pair.get("reserve_y_amount") is not None else None, - - # Fee structure - base_fee_percentage=pair.get("base_fee_percentage"), - max_fee_percentage=pair.get("max_fee_percentage"), - protocol_fee_percentage=pair.get("protocol_fee_percentage"), - - # APR/APY - apr=Decimal(str(pair.get("apr", 0))) if pair.get("apr") is not None else None, - apy=Decimal(str(pair.get("apy", 0))) if pair.get("apy") is not None else None, - farm_apr=Decimal(str(pair.get("farm_apr"))) if pair.get("farm_apr") is not None else None, - farm_apy=Decimal(str(pair.get("farm_apy"))) if pair.get("farm_apy") is not None else None, - - # Volume and fees - volume_24h=Decimal(str(pair.get("trade_volume_24h", 0))) if pair.get("trade_volume_24h") is not None else None, - fees_24h=Decimal(str(pair.get("fees_24h", 0))) if pair.get("fees_24h") is not None else None, - today_fees=Decimal(str(pair.get("today_fees"))) if pair.get("today_fees") is not None else None, - cumulative_trade_volume=pair.get("cumulative_trade_volume"), - cumulative_fee_volume=pair.get("cumulative_fee_volume"), - - # Time-based metrics - volume=to_time_metrics(pair.get("volume")), - fees=to_time_metrics(pair.get("fees")), - fee_tvl_ratio=to_time_metrics(pair.get("fee_tvl_ratio")), - - # Rewards - reward_mint_x=pair.get("reward_mint_x"), - reward_mint_y=pair.get("reward_mint_y"), - - # Metadata - tags=pair.get("tags"), - is_verified=pair.get("is_verified", False), - is_blacklisted=pair.get("is_blacklisted"), - hide=pair.get("hide"), - launchpad=pair.get("launchpad") - )) - - total = meteora_data.get("total", len(pools)) + for pool in pool_list: + trading_pair = pool.get("name", f"{pool.get('baseTokenSymbol', '?')}-{pool.get('quoteTokenSymbol', '?')}") + pools.append(CLMMPoolListItem( + address=pool.get("address", ""), + name=pool.get("name", ""), + trading_pair=trading_pair, + mint_x=pool.get("baseTokenAddress", ""), + mint_y=pool.get("quoteTokenAddress", ""), + bin_step=pool.get("binStep", 0), + current_price=Decimal(str(pool.get("price", 0))), + liquidity=str(pool.get("tvl", "0")), + apr=Decimal(str(pool.get("apr", 0))) if pool.get("apr") is not None else None, + apy=Decimal(str(pool.get("apy", 0))) if pool.get("apy") is not None else None, + volume_24h=Decimal(str(pool.get("volume24h", 0))) if pool.get("volume24h") is not None else None, + fees_24h=Decimal(str(pool.get("fees24h", 0))) if pool.get("fees24h") is not None else None, + base_fee_percentage=str(pool.get("baseFee")) if pool.get("baseFee") is not None else None, + )) + + total = gateway_data.get("total", len(pools)) return CLMMPoolListResponse( pools=pools, total=total, page=page, - limit=limit + pageSize=limit ) except HTTPException: diff --git a/services/accounts_service.py b/services/accounts_service.py index 70f5a1fd..8215dc94 100644 --- a/services/accounts_service.py +++ b/services/accounts_service.py @@ -478,6 +478,7 @@ def __init__(self, self._trading_service = None # TradingService # Initialize Gateway client + self.gateway_base_url = gateway_url self.gateway_client = GatewayClient(gateway_url) # Initialize Gateway transaction poller From d835d81423b38cf84759610ceb883b17f686db45 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Sat, 2 May 2026 09:16:47 -0700 Subject: [PATCH 3/5] fix(portfolio): refresh=true now fetches fresh balances from exchange Previously, portfolio state refresh=true did not actually fetch fresh balances from exchanges - it only read cached connector data. Changes: - _get_connector_tokens_info now calls connector._update_balances() - Added skip_balance_refresh param to avoid double fetch in background loop - Errors during balance refresh log warning and use stale data gracefully - Added tests for refresh behavior Co-Authored-By: Claude Opus 4.5 --- models/trading.py | 12 +++-- routers/portfolio.py | 14 ++---- services/accounts_service.py | 23 +++++++-- test/test_portfolio_state.py | 97 ++++++++++++++++++++++++++++++++++++ 4 files changed, 127 insertions(+), 19 deletions(-) create mode 100644 test/test_portfolio_state.py diff --git a/models/trading.py b/models/trading.py index a7ee0d7d..94972762 100644 --- a/models/trading.py +++ b/models/trading.py @@ -1,8 +1,10 @@ -from typing import Dict, List, Optional, Any, Literal -from pydantic import BaseModel, Field, field_validator -from decimal import Decimal from datetime import datetime -from hummingbot.core.data_type.common import OrderType, TradeType, PositionAction +from decimal import Decimal +from typing import Any, Dict, List, Literal, Optional + +from hummingbot.core.data_type.common import OrderType, PositionAction, TradeType +from pydantic import BaseModel, Field, field_validator + from .pagination import PaginationParams, TimeRangePaginationParams @@ -190,7 +192,7 @@ class PortfolioStateFilterRequest(BaseModel): account_names: Optional[List[str]] = Field(default=None, description="List of account names to filter by") connector_names: Optional[List[str]] = Field(default=None, description="List of connector names to filter by") skip_gateway: bool = Field(default=False, description="Skip Gateway wallet balance updates for faster CEX-only queries") - refresh: bool = Field(default=False, description="If True, refresh balances before returning. If False, return cached state") + refresh: bool = Field(default=False, description="If True, refresh balances from exchanges. If False, return cached state.") class PortfolioHistoryFilterRequest(TimeRangePaginationParams): diff --git a/routers/portfolio.py b/routers/portfolio.py index 671bd07f..0981701b 100644 --- a/routers/portfolio.py +++ b/routers/portfolio.py @@ -1,16 +1,12 @@ -from typing import Dict, List, Optional from datetime import datetime +from typing import Dict, List, Optional -from fastapi import APIRouter, HTTPException, Depends, Query +from fastapi import APIRouter, Depends, HTTPException, Query -from models.trading import ( - PortfolioStateFilterRequest, - PortfolioHistoryFilterRequest, - PortfolioDistributionFilterRequest, -) -from services.accounts_service import AccountsService from deps import get_accounts_service from models import PaginatedResponse +from models.trading import PortfolioDistributionFilterRequest, PortfolioHistoryFilterRequest, PortfolioStateFilterRequest +from services.accounts_service import AccountsService router = APIRouter(tags=["Portfolio"], prefix="/portfolio") @@ -28,7 +24,7 @@ async def get_portfolio_state( - account_names: Optional list of account names to filter by - connector_names: Optional list of connector names to filter by - skip_gateway: If True, skip Gateway wallet balance updates for faster CEX-only queries - - refresh: If True, refresh balances before returning. If False (default), return cached state + - refresh: If True, refresh balances from exchanges. If False, return cached state. Returns: Dict containing account states with connector balances and token information diff --git a/services/accounts_service.py b/services/accounts_service.py index 8215dc94..28ecf42b 100644 --- a/services/accounts_service.py +++ b/services/accounts_service.py @@ -608,7 +608,8 @@ async def _refresh_and_get_tokens_info(self, connector, connector_name: str, acc await self._connector_service._update_connector_state(connector, connector_name, account_name) except Exception as e: logger.error(f"Error refreshing {connector_name}, using stale data: {e}") - return await self._get_connector_tokens_info(connector, connector_name) + # skip_balance_refresh=True since _update_connector_state already called _update_balances + return await self._get_connector_tokens_info(connector, connector_name, skip_balance_refresh=True) async def update_account_state_loop(self): """ @@ -815,12 +816,24 @@ async def update_account_state( else: self.accounts_state[account_name][connector_name] = result - async def _get_connector_tokens_info(self, connector, connector_name: str) -> List[Dict]: + 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 RateOracle cached prices. - Tries the RateOracle (instant, in-memory) first for each token. - Only falls back to a batch exchange call for tokens the oracle can't price. + Fetches fresh balances from the exchange, then tries the RateOracle (instant, in-memory) + first for each token price. Only falls back to a batch exchange call for tokens the oracle can't price. + + Args: + connector: The connector instance + connector_name: Name of the connector + skip_balance_refresh: If True, skip fetching fresh balances (use when caller already refreshed) """ + # Fetch fresh balances from the exchange unless caller already did + if not skip_balance_refresh and hasattr(connector, '_update_balances'): + try: + await connector._update_balances() + except Exception as e: + logger.warning(f"Failed to refresh balances for {connector_name}, using cached data: {e}") + balances = [{"token": key, "units": value} for key, value in connector.get_all_balances().items() if value != Decimal("0") and key not in settings.banned_tokens] @@ -2254,4 +2267,4 @@ def get_unwrapped_token(self, token: str) -> str: """Get the unwrapped version of a wrapped token symbol (e.g., WSOL -> SOL).""" if token.startswith("W") and token[1:] in self.potential_wrapped_tokens: return token[1:] - return token \ No newline at end of file + return token diff --git a/test/test_portfolio_state.py b/test/test_portfolio_state.py new file mode 100644 index 00000000..51234cec --- /dev/null +++ b/test/test_portfolio_state.py @@ -0,0 +1,97 @@ +""" +Tests for Portfolio State refresh behavior. + +Run with: pytest test/test_portfolio_state.py -v +""" +from decimal import Decimal +from unittest.mock import AsyncMock, MagicMock + +import pytest + +pytest.importorskip("hummingbot") + + +class TestPortfolioStateRefresh: + """Tests for portfolio state refresh behavior.""" + + @pytest.mark.asyncio + async def test_refresh_true_calls_update_account_state(self): + """refresh=True should call update_account_state.""" + from models.trading import PortfolioStateFilterRequest + from routers.portfolio import get_portfolio_state + + mock_service = MagicMock() + mock_service.update_account_state = AsyncMock() + mock_service.get_accounts_state.return_value = {} + + request = PortfolioStateFilterRequest(refresh=True) + await get_portfolio_state(request, mock_service) + + mock_service.update_account_state.assert_called_once() + + @pytest.mark.asyncio + async def test_refresh_false_does_not_call_update_account_state(self): + """refresh=False should NOT call update_account_state.""" + from models.trading import PortfolioStateFilterRequest + from routers.portfolio import get_portfolio_state + + mock_service = MagicMock() + mock_service.update_account_state = AsyncMock() + mock_service.get_accounts_state.return_value = {} + + request = PortfolioStateFilterRequest(refresh=False) + await get_portfolio_state(request, mock_service) + + mock_service.update_account_state.assert_not_called() + + +class TestBalanceRefresh: + """Tests for _get_connector_tokens_info balance refresh.""" + + @pytest.fixture + def accounts_service(self): + """Create AccountsService with mocked dependencies.""" + from services.accounts_service import AccountsService + + service = AccountsService.__new__(AccountsService) + service._market_data_service = MagicMock() + service._market_data_service.get_rate.return_value = Decimal("1") + return service + + @pytest.fixture + def mock_connector(self): + """Create a mock connector.""" + connector = MagicMock() + connector._update_balances = AsyncMock() + connector.get_all_balances.return_value = {"USDT": Decimal("1000")} + connector.get_available_balance.return_value = Decimal("1000") + return connector + + @pytest.mark.asyncio + async def test_calls_update_balances(self, accounts_service, mock_connector): + """_get_connector_tokens_info should call _update_balances.""" + await accounts_service._get_connector_tokens_info(mock_connector, "okx") + + mock_connector._update_balances.assert_called_once() + + @pytest.mark.asyncio + async def test_skips_update_balances_when_requested(self, accounts_service, mock_connector): + """skip_balance_refresh=True should skip _update_balances.""" + await accounts_service._get_connector_tokens_info( + mock_connector, "okx", skip_balance_refresh=True + ) + + mock_connector._update_balances.assert_not_called() + + @pytest.mark.asyncio + async def test_balance_failure_preserves_stale_data(self, accounts_service, mock_connector): + """_update_balances failure should preserve stale cached data.""" + mock_connector._update_balances = AsyncMock(side_effect=Exception("API error")) + mock_connector.get_all_balances.return_value = {"USDT": Decimal("500")} + + result = await accounts_service._get_connector_tokens_info(mock_connector, "okx") + + # Should still return data from get_all_balances (stale cache) + assert len(result) == 1 + assert result[0]["token"] == "USDT" + assert result[0]["units"] == 500.0 From ffeabd35792301f49b2fbd31106b8e6b567641f0 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Wed, 6 May 2026 16:52:23 -0700 Subject: [PATCH 4/5] Add save_network_token endpoint and include default wallet in wallet list - Add POST /gateway/networks/{network_id}/tokens/save/{token_address} endpoint - Update get_gateway_wallets to include default_address for each chain - Fix gateway_client save_token to pass json={} for correct content-type Co-Authored-By: Claude Opus 4.5 --- routers/gateway.py | 57 ++++++++++++++++++++++++++++++++++++ services/accounts_service.py | 10 ++++++- services/gateway_client.py | 9 +++++- 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/routers/gateway.py b/routers/gateway.py index aa32fa20..fe345548 100644 --- a/routers/gateway.py +++ b/routers/gateway.py @@ -528,6 +528,63 @@ async def add_network_token( raise HTTPException(status_code=500, detail=f"Error adding token: {str(e)}") +@router.post("/networks/{network_id}/tokens/save/{token_address}") +async def save_network_token( + network_id: str, + token_address: str, + accounts_service: AccountsService = Depends(get_accounts_service) +) -> Dict: + """ + Save a token by address - auto-fetches token info from GeckoTerminal. + + This is the simplest way to add a token. Just provide the address and + the API will fetch symbol, name, and decimals automatically. + + Args: + network_id: Network ID in format 'chain-network' (e.g., 'solana-mainnet-beta', 'ethereum-mainnet') + token_address: Token contract address + + Example: POST /gateway/networks/solana-mainnet-beta/tokens/save/9QFfgxdSqH5zT7j6rZb1y6SZhw2aFtcQu2r6BuYpump + """ + try: + if not await accounts_service.gateway_client.ping(): + raise HTTPException(status_code=503, detail="Gateway service is not available") + + # Parse network_id into chain and network + parts = network_id.split('-', 1) + if len(parts) != 2: + 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.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 { + "success": True, + "message": result.get("message", f"Token saved to {network_id}"), + "token": { + "symbol": token_info.get("symbol"), + "address": token_info.get("address", token_address), + "decimals": token_info.get("decimals"), + "name": token_info.get("name"), + "network_id": network_id + } + } + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error saving token: {str(e)}") + + @router.delete("/networks/{network_id}/tokens/{token_address}") async def delete_network_token( network_id: str, diff --git a/services/accounts_service.py b/services/accounts_service.py index 28ecf42b..9d48830e 100644 --- a/services/accounts_service.py +++ b/services/accounts_service.py @@ -2015,13 +2015,21 @@ async def get_gateway_wallets(self) -> List[Dict]: Get all wallets from Gateway. Gateway manages its own encrypted wallets. Returns: - List of wallet information from Gateway + List of wallet information from Gateway, with default_address included for each chain """ if not await self.gateway_client.ping(): raise HTTPException(status_code=503, detail="Gateway service is not available") try: wallets = await self.gateway_client.get_wallets() + + # Enrich with default wallet info for each chain + for wallet_group in wallets: + chain = wallet_group.get("chain") + if chain: + default_wallet = await self.gateway_client.get_default_wallet_address(chain) + wallet_group["default_address"] = default_wallet or "" + return wallets except Exception as e: logger.error(f"Error getting Gateway wallets: {e}") diff --git a/services/gateway_client.py b/services/gateway_client.py index 2769e75f..245ffe64 100644 --- a/services/gateway_client.py +++ b/services/gateway_client.py @@ -68,7 +68,7 @@ async def _request(self, method: str, path: str, params: Dict = None, json: Dict return {"error": error_body, "status": response.status} return await response.json() elif method == "POST": - async with session.post(url, json=json) as response: + async with session.post(url, params=params, json=json) as response: if not response.ok: error_body = await self._get_error_body(response) logger.warning(f"Gateway request failed: {method} {url} - {response.status} - {error_body}") @@ -255,6 +255,13 @@ async def delete_token(self, chain: str, network: str, token_address: str) -> Di "network": network }) + async def save_token(self, chain: str, network: str, token_address: str) -> Dict: + """Save a token by address - auto-fetches info from GeckoTerminal""" + chain_network = f"{chain}-{network}" + return await self._request("POST", f"tokens/save/{token_address}", params={ + "chainNetwork": chain_network + }, json={}) + async def get_config(self, namespace: str) -> Dict: """Get configuration for a specific namespace (connector or chain-network)""" return await self._request("GET", "config", params={"namespace": namespace}) From ade1c89c25b36259e53d6df79cf828de2537f1f2 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Thu, 7 May 2026 19:33:14 -0700 Subject: [PATCH 5/5] feat(gateway): add API keys management endpoints - Add GET /gateway/apiKeys to retrieve all configured API keys - Add POST /gateway/apiKeys to update API keys - Add UpdateApiKeysRequest model - Add get_api_keys() and update_api_keys() to gateway_client Co-Authored-By: Claude Opus 4.5 --- models/__init__.py | 2 + models/gateway.py | 11 ++++++ routers/gateway.py | 78 ++++++++++++++++++++++++++++++++++++++ services/gateway_client.py | 25 ++++++++++++ 4 files changed, 116 insertions(+) diff --git a/models/__init__.py b/models/__init__.py index 6da5b2e1..42208f6c 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -87,6 +87,7 @@ SendTransactionRequest, SetDefaultWalletRequest, ShowPrivateKeyRequest, + UpdateApiKeysRequest, ) # Gateway Trading models (Swap + CLMM only, AMM removed) @@ -290,6 +291,7 @@ "GatewayBalanceRequest", "AddPoolRequest", "AddTokenRequest", + "UpdateApiKeysRequest", # Backtesting models "BacktestingConfig", # Pagination models diff --git a/models/gateway.py b/models/gateway.py index 29ba1476..003723c1 100644 --- a/models/gateway.py +++ b/models/gateway.py @@ -107,3 +107,14 @@ class GatewayBalanceRequest(BaseModel): account_name: str = Field(description="Account name") chain: str = Field(description="Blockchain chain") tokens: Optional[List[str]] = Field(default=None, description="List of token symbols to query (optional)") + + +# ============================================ +# API Keys Management Models +# ============================================ + +class UpdateApiKeysRequest(BaseModel): + """Request to update Gateway API keys""" + api_keys: dict = Field( + description="Dict mapping provider name to API key value (e.g., {'helius': 'abc123', 'infura': 'xyz789'})" + ) diff --git a/routers/gateway.py b/routers/gateway.py index fe345548..fe9e6744 100644 --- a/routers/gateway.py +++ b/routers/gateway.py @@ -12,6 +12,7 @@ GatewayStatus, SendTransactionRequest, ShowPrivateKeyRequest, + UpdateApiKeysRequest, ) from services.accounts_service import AccountsService from services.gateway_service import GatewayService @@ -246,6 +247,83 @@ async def update_connector_config( raise HTTPException(status_code=500, detail=f"Error updating connector config: {str(e)}") +# ============================================ +# API Keys +# ============================================ + +@router.get("/apiKeys") +async def get_api_keys(accounts_service: AccountsService = Depends(get_accounts_service)) -> Dict: + """ + Get all configured API keys from Gateway. + + Returns a dict mapping provider name to API key value. + Example response: + { + "helius": "46951ec2-16af-4fc0-a5df-970b0eb925b7", + "infura": "920646320ec3463fa1b5235be9fa48d3", + "coingecko": "CG-Rw786jTpNmV1MvRrqpDAHR6r", + "etherscan": "" + } + """ + try: + 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 + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error getting API keys: {str(e)}") + + +@router.post("/apiKeys") +async def update_api_keys( + request: UpdateApiKeysRequest, + accounts_service: AccountsService = Depends(get_accounts_service) +) -> Dict: + """ + Update API keys in Gateway configuration. + + Args: + request: Contains api_keys dict mapping provider name to API key value + + Example request: + { + "api_keys": { + "helius": "new-api-key-value", + "infura": "another-api-key" + } + } + + Note: After updating API keys, restart Gateway for changes to take effect. + """ + try: + if not await accounts_service.gateway_client.ping(): + raise HTTPException(status_code=503, detail="Gateway service is not available") + + results = await accounts_service.gateway_client.update_api_keys(request.api_keys) + + # Check for any errors in the results + errors = [r for r in results if r and "error" in r] + if errors: + raise HTTPException(status_code=400, detail=f"Failed to update some API keys: {errors}") + + return { + "success": True, + "message": f"Updated {len(request.api_keys)} API key(s). Restart Gateway for changes to take effect.", + "restart_required": True, + "restart_endpoint": "POST /gateway/restart", + "updated_keys": list(request.api_keys.keys()) + } + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error updating API keys: {str(e)}") + + # ============================================ # Chains (Networks) and Tokens # ============================================ diff --git a/services/gateway_client.py b/services/gateway_client.py index 245ffe64..84754826 100644 --- a/services/gateway_client.py +++ b/services/gateway_client.py @@ -274,6 +274,31 @@ async def update_config(self, namespace: str, path: str, value: any) -> Dict: "value": value }) + async def get_api_keys(self) -> Dict: + """Get all configured API keys from Gateway""" + return await self._request("GET", "config", params={"namespace": "apiKeys"}) + + async def update_api_keys(self, api_keys: Dict[str, str]) -> List[Dict]: + """ + Update API keys in Gateway configuration. + + Args: + api_keys: Dict mapping provider name to API key value + (e.g., {"helius": "abc123", "infura": "xyz789"}) + + Returns: + List of results for each API key update + """ + results = [] + for provider, api_key in api_keys.items(): + result = await self._request("POST", "config/update", json={ + "namespace": "apiKeys", + "path": provider, + "value": api_key + }) + results.append(result) + return results + async def get_pools( self, chain: str,