diff --git a/bots/credentials/master_account/conf_client.yml b/bots/credentials/master_account/conf_client.yml index cbba78d4..21e00ebd 100644 --- a/bots/credentials/master_account/conf_client.yml +++ b/bots/credentials/master_account/conf_client.yml @@ -112,7 +112,7 @@ anonymized_metrics_mode: # A source for rate oracle, currently ascend_ex, binance, coin_gecko, coin_cap, kucoin, gate_io rate_oracle_source: - name: binance + name: gate_io # A universal token which to display tokens values in, e.g. USD,EUR,BTC global_token: @@ -133,7 +133,6 @@ tables_format: psql paper_trade: paper_trade_exchanges: - - binance - kucoin - ascend_ex - gate_io diff --git a/main.py b/main.py index b53ede81..0f2d996b 100644 --- a/main.py +++ b/main.py @@ -215,13 +215,10 @@ async def lifespan(app: FastAPI): try: from hummingbot.strategy_v2.executors.lp_executor.data_types import LPExecutorConfig from hummingbot.strategy_v2.executors.lp_executor.lp_executor import LPExecutor - print(f"[LP-FIX] imports OK. Registry before: {list(ExecutorService.EXECUTOR_REGISTRY.keys())}", flush=True) ExecutorService.EXECUTOR_REGISTRY["lp_executor"] = (LPExecutor, LPExecutorConfig) - print(f"[LP-FIX] Registry after: {list(ExecutorService.EXECUTOR_REGISTRY.keys())}", flush=True) + logging.debug("lp_executor registered in ExecutorService") except Exception as e: - import traceback - print(f"[LP-FIX] FAILED: {e}", flush=True) - traceback.print_exc() + logging.warning(f"Failed to register lp_executor: {e}") # ========================================================================= # 5. Other Services diff --git a/models/__init__.py b/models/__init__.py index 62da9281..b000dd01 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -43,6 +43,7 @@ StopAndArchiveResponse, StopBotAction, V2ControllerDeployment, + V2ScriptDeployment, ) # Connector models @@ -84,6 +85,7 @@ GatewayWalletCredential, GatewayWalletInfo, SendTransactionRequest, + SetDefaultWalletRequest, ShowPrivateKeyRequest, ) @@ -213,6 +215,7 @@ "StopAndArchiveRequest", "StopAndArchiveResponse", "V2ControllerDeployment", + "V2ScriptDeployment", # Trading models "TradeRequest", "TradeResponse", @@ -282,6 +285,7 @@ "CreateWalletRequest", "ShowPrivateKeyRequest", "SendTransactionRequest", + "SetDefaultWalletRequest", "GatewayWalletCredential", "GatewayWalletInfo", "GatewayBalanceRequest", diff --git a/models/bot_orchestration.py b/models/bot_orchestration.py index d71252b0..a23dd242 100644 --- a/models/bot_orchestration.py +++ b/models/bot_orchestration.py @@ -94,6 +94,16 @@ class StopAndArchiveResponse(BaseModel): # Bot deployment models +class V2ScriptDeployment(BaseModel): + """Configuration for deploying a bot with a script""" + instance_name: str = Field(description="Unique name for the bot instance") + credentials_profile: str = Field(description="Name of the credentials profile to use") + image: str = Field(default="hummingbot/hummingbot:latest", description="Docker image for the Hummingbot instance") + script: Optional[str] = Field(default=None, description="Script name to run (without .py extension)") + script_config: Optional[str] = Field(default=None, description="Script configuration file name (without .yml extension)") + headless: bool = Field(default=False, description="Run in headless mode (no UI)") + + class V2ControllerDeployment(BaseModel): """Configuration for deploying a bot with controllers""" instance_name: str = Field(description="Unique name for the bot instance") diff --git a/models/executors.py b/models/executors.py index dda1b455..d996017f 100644 --- a/models/executors.py +++ b/models/executors.py @@ -211,7 +211,8 @@ class PositionsSummaryResponse(BaseModel): "twap_executor", "xemm_executor", "order_executor", - "lp_executor" + "lp_executor", + "swap_executor" ] @@ -246,14 +247,14 @@ class CreateExecutorRequest(BaseModel): }, { "summary": "LP Executor", - "description": "Create an LP position on a CLMM DEX (Meteora, Raydium)", + "description": "Create an LP position on a CLMM DEX", "value": { "account_name": "master_account", "executor_config": { "type": "lp_executor", - "connector_name": "meteora/clmm", - "trading_pair": "SOL-USDC", + "connector_name": "meteora", "pool_address": "HTvjzsfX3yU6BUodCjZ5vZkUrAxMDTrBs3CJaq43ashR", + "network": "solana-mainnet-beta", "lower_price": "80", "upper_price": "100", "base_amount": "0", @@ -265,6 +266,22 @@ class CreateExecutorRequest(BaseModel): "keep_position": False } } + }, + { + "summary": "Swap Executor", + "description": "Execute a single swap on Gateway AMM connectors (Jupiter, Raydium, etc.)", + "value": { + "account_name": "master_account", + "executor_config": { + "type": "swap_executor", + "network": "solana-mainnet-beta", + "trading_pair": "SOL-USDC", + "side": 2, + "amount": "0.1", + "slippage_pct": "0.5", + "swap_providers": ["jupiter/router", "meteora/clmm", "orca/clmm"] + } + } } ] } diff --git a/models/gateway.py b/models/gateway.py index 11b97132..849acd4c 100644 --- a/models/gateway.py +++ b/models/gateway.py @@ -50,10 +50,10 @@ class SendTransactionRequest(BaseModel): class GatewayWalletCredential(BaseModel): - """Credentials for connecting a Gateway wallet""" + """Credentials for adding an existing wallet to Gateway""" chain: str = Field(description="Blockchain chain (e.g., 'solana', 'ethereum')") private_key: str = Field(description="Wallet private key") - network: Optional[str] = Field(default=None, description="Network to use (defaults to chain's default)") + set_default: bool = Field(default=True, description="Set as default wallet for this chain") class GatewayWalletInfo(BaseModel): @@ -63,6 +63,12 @@ class GatewayWalletInfo(BaseModel): network: str = Field(description="Network the wallet is configured for") +class SetDefaultWalletRequest(BaseModel): + """Request to set the default wallet for a chain""" + chain: str = Field(description="Blockchain chain (e.g., 'solana', 'ethereum')") + address: str = Field(description="Wallet address to set as default") + + # ============================================ # Pool and Token Management Models # ============================================ diff --git a/routers/accounts.py b/routers/accounts.py index 8d6de870..a5e4106a 100644 --- a/routers/accounts.py +++ b/routers/accounts.py @@ -1,12 +1,11 @@ -from typing import Dict, List, Optional -from datetime import datetime +from typing import Dict, List -from fastapi import APIRouter, HTTPException, Depends, Query +from fastapi import APIRouter, Depends, HTTPException from starlette import status -from services.accounts_service import AccountsService from deps import get_accounts_service -from models import PaginatedResponse, GatewayWalletCredential, GatewayWalletInfo +from models import GatewayWalletCredential, SetDefaultWalletRequest +from services.accounts_service import AccountsService router = APIRouter(tags=["Accounts"], prefix="/accounts") @@ -15,7 +14,7 @@ async def list_accounts(accounts_service: AccountsService = Depends(get_accounts_service)): """ Get a list of all account names in the system. - + Returns: List of account names """ @@ -51,13 +50,13 @@ async def list_account_credentials(account_name: str, async def add_account(account_name: str, accounts_service: AccountsService = Depends(get_accounts_service)): """ Create a new account with default configuration files. - + Args: account_name: Name of the new account to create - + Returns: Success message when account is created - + Raises: HTTPException: 400 if account already exists """ @@ -72,13 +71,13 @@ async def add_account(account_name: str, accounts_service: AccountsService = Dep async def delete_account(account_name: str, accounts_service: AccountsService = Depends(get_accounts_service)): """ Delete an account and all its associated credentials. - + Args: account_name: Name of the account to delete - + Returns: Success message when account is deleted - + Raises: HTTPException: 400 if trying to delete master account, 404 if account not found """ @@ -95,14 +94,14 @@ async def delete_account(account_name: str, accounts_service: AccountsService = async def delete_credential(account_name: str, connector_name: str, accounts_service: AccountsService = Depends(get_accounts_service)): """ Delete a specific connector credential for an account. - + Args: account_name: Name of the account connector_name: Name of the connector to delete credentials for - + Returns: Success message when credential is deleted - + Raises: HTTPException: 404 if credential not found """ @@ -168,10 +167,11 @@ async def add_gateway_wallet( accounts_service: AccountsService = Depends(get_accounts_service) ): """ - Add a wallet to Gateway. Gateway handles encryption and storage internally. + Add an existing wallet to Gateway using its private key. + Gateway handles encryption and storage internally. Args: - wallet_credential: Wallet credentials (chain and private_key) + wallet_credential: Wallet credentials (chain, private_key, and optional set_default) Returns: Wallet information from Gateway including address @@ -182,7 +182,8 @@ async def add_gateway_wallet( try: result = await accounts_service.add_gateway_wallet( chain=wallet_credential.chain, - private_key=wallet_credential.private_key + private_key=wallet_credential.private_key, + set_default=wallet_credential.set_default ) return result except HTTPException: @@ -191,6 +192,57 @@ async def add_gateway_wallet( raise HTTPException(status_code=500, detail=str(e)) +@router.post("/gateway/wallet/set-default") +async def set_default_gateway_wallet( + request: SetDefaultWalletRequest, + accounts_service: AccountsService = Depends(get_accounts_service) +) -> Dict: + """ + Set the default wallet for a chain in Gateway. + + When multiple wallets are configured for a chain, this endpoint allows + switching which wallet is used as the default for operations. + + Args: + request: Contains chain and wallet address to set as default + + Returns: + Dict with success status and updated wallet info. + + Example: POST /accounts/gateway/wallet/set-default + { + "chain": "solana", + "address": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5" + } + """ + 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.set_default_wallet( + chain=request.chain, + address=request.address + ) + + if result is None: + raise HTTPException(status_code=502, detail="Failed to set default wallet: Gateway returned no response") + + if "error" in result: + raise HTTPException(status_code=400, detail=f"Failed to set default wallet: {result.get('error')}") + + return { + "success": True, + "message": f"Set {request.address} as default wallet for {request.chain}", + "chain": request.chain, + "address": request.address + } + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error setting default wallet: {str(e)}") + + @router.delete("/gateway/{chain}/{address}") async def remove_gateway_wallet( chain: str, @@ -217,5 +269,3 @@ async def remove_gateway_wallet( raise except Exception as e: raise HTTPException(status_code=500, detail=str(e)) - - diff --git a/routers/bot_orchestration.py b/routers/bot_orchestration.py index 5634d3eb..994fe60f 100644 --- a/routers/bot_orchestration.py +++ b/routers/bot_orchestration.py @@ -7,7 +7,7 @@ from database import AsyncDatabaseManager, BotRunRepository from deps import get_bot_archiver, get_bots_orchestrator, get_database_manager, get_docker_service -from models import StartBotAction, StopBotAction, V2ControllerDeployment +from models import StartBotAction, StopBotAction, V2ControllerDeployment, V2ScriptDeployment from services.bots_orchestrator import BotsOrchestrator from services.docker_service import DockerService from utils.bot_archiver import BotArchiver @@ -122,6 +122,51 @@ async def get_bot_history( return {"status": "success", "response": response} +@router.get("/{bot_name}/lphistory") +async def get_bot_lp_history( + bot_name: str, + days: int = 0, + verbose: bool = False, + precision: int = None, + timeout: float = 30.0, + bots_manager: BotsOrchestrator = Depends(get_bots_orchestrator) +): + """ + Get LP (liquidity provider) position history for a bot. + + This endpoint returns LP-specific data including position updates, + fees collected, and liquidity additions/removals. Use this for + AMM/CLMM strategies like Meteora. + + Args: + bot_name: Name of the bot to get LP history for + days: Number of days of history to retrieve (0 for all) + verbose: Whether to include verbose output + precision: Decimal precision for numerical values + timeout: Timeout in seconds for the operation + bots_manager: Bot orchestrator service dependency + + Returns: + Dictionary with LP position history including: + - position_address: The LP position address + - order_action: ADD or REMOVE + - trading_pair: The trading pair (e.g., SOL-USDC) + - base_amount, quote_amount: Amounts added/removed + - base_fee, quote_fee: Fees collected + - lower_price, upper_price: Price range of position + - mid_price: Price at time of operation + - trade_fee: Transaction fees paid + """ + response = await bots_manager.get_bot_lp_history( + bot_name, + days=days, + verbose=verbose, + precision=precision, + timeout=timeout + ) + return {"status": "success", "response": response} + + @router.post("/start-bot") async def start_bot( action: StartBotAction, @@ -683,3 +728,65 @@ async def deploy_v2_controllers( except Exception as e: logging.error(f"Error deploying V2 controllers: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/deploy-v2-script") +async def deploy_v2_script( + deployment: V2ScriptDeployment, + docker_manager: DockerService = Depends(get_docker_service), + db_manager: AsyncDatabaseManager = Depends(get_database_manager) +): + """ + Deploy a V2 script bot with optional script configuration. + This endpoint creates and starts a Hummingbot instance running the specified script. + + Args: + deployment: V2ScriptDeployment configuration containing instance name, credentials, + optional script name and configuration + docker_manager: Docker service dependency + db_manager: Database manager dependency + + Returns: + Dictionary with deployment response including instance details + + Raises: + HTTPException: 500 if deployment fails + """ + try: + # Generate unique instance name with timestamp + timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") + unique_instance_name = f"{deployment.instance_name}-{timestamp}" + + # Update deployment with unique name + deployment.instance_name = unique_instance_name + + # Create the hummingbot instance + response = docker_manager.create_hummingbot_instance(deployment) + + if response.get("success"): + response["unique_instance_name"] = unique_instance_name + + # Track bot run if deployment was successful + try: + async with db_manager.get_session_context() as session: + bot_run_repo = BotRunRepository(session) + await bot_run_repo.create_bot_run( + bot_name=unique_instance_name, + instance_name=unique_instance_name, + strategy_type="script", + strategy_name=deployment.script or "default", + account_name=deployment.credentials_profile, + config_name=deployment.script_config, + image_version=deployment.image, + deployment_config=deployment.dict() + ) + logger.info(f"Created bot run record for script deployment {unique_instance_name}") + except Exception as e: + logger.error(f"Failed to create bot run record: {e}") + # Don't fail the deployment if bot run creation fails + + return response + + except Exception as e: + logging.error(f"Error deploying V2 script: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/routers/executors.py b/routers/executors.py index 8cb8545c..2b56fb99 100644 --- a/routers/executors.py +++ b/routers/executors.py @@ -241,6 +241,11 @@ async def get_available_executor_types(): "type": "lp_executor", "description": "LP position management for CLMM pools (Meteora, Raydium) ", "use_case": "Automated liquidity provision with position tracking" + }, + { + "type": "swap_executor", + "description": "Single swap execution on Gateway AMM connectors", + "use_case": "Executing swaps on DEXs like Jupiter with retry logic" } ] } diff --git a/routers/gateway.py b/routers/gateway.py index 8bca7309..6c599b87 100644 --- a/routers/gateway.py +++ b/routers/gateway.py @@ -1,19 +1,20 @@ -from fastapi import APIRouter, HTTPException, Depends, Query -from typing import Optional, Dict, List import re +from typing import Dict, List, Optional +from fastapi import APIRouter, Depends, HTTPException, Query + +from deps import get_accounts_service, get_gateway_service from models import ( - GatewayConfig, - GatewayStatus, AddPoolRequest, AddTokenRequest, CreateWalletRequest, - ShowPrivateKeyRequest, + GatewayConfig, + GatewayStatus, SendTransactionRequest, + ShowPrivateKeyRequest, ) -from services.gateway_service import GatewayService from services.accounts_service import AccountsService -from deps import get_gateway_service, get_accounts_service +from services.gateway_service import GatewayService router = APIRouter(tags=["Gateway"], prefix="/gateway") @@ -605,9 +606,7 @@ async def add_network_token( return { "success": True, - "message": f"Token {token_request.symbol} added to {network_id}. Restart Gateway for changes to take effect.", - "restart_required": True, - "restart_endpoint": "POST /gateway/restart", + "message": f"Token {token_request.symbol} added to {network_id}.", "token": { "symbol": token_request.symbol, "address": token_request.address, @@ -660,9 +659,7 @@ async def delete_network_token( return { "success": True, - "message": f"Token {token_address} deleted from {network_id}. Restart Gateway for changes to take effect.", - "restart_required": True, - "restart_endpoint": "POST /gateway/restart", + "message": f"Token {token_address} deleted from {network_id}.", "token_address": token_address, "network_id": network_id } diff --git a/routers/gateway_clmm.py b/routers/gateway_clmm.py index 72292459..cb23e9d9 100644 --- a/routers/gateway_clmm.py +++ b/routers/gateway_clmm.py @@ -705,191 +705,191 @@ async def open_clmm_position( raise HTTPException(status_code=500, detail=f"Error opening CLMM position: {str(e)}") -# @router.post("/clmm/add") -# async def add_liquidity_to_clmm_position( -# request: CLMMAddLiquidityRequest, -# accounts_service: AccountsService = Depends(get_accounts_service), -# db_manager: AsyncDatabaseManager = Depends(get_database_manager) -# ): -# """ -# Add MORE liquidity to an EXISTING CLMM position. -# -# Example: -# connector: 'meteora' -# network: 'solana-mainnet-beta' -# position_address: '...' -# base_token_amount: 0.5 -# quote_token_amount: 50.0 -# slippage_pct: 1 -# wallet_address: (optional) -# -# Returns: -# Transaction hash -# """ -# try: -# 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) -# -# # Get wallet address -# wallet_address = await accounts_service.gateway_client.get_wallet_address_or_default( -# chain=chain, -# wallet_address=request.wallet_address -# ) -# -# # Add liquidity to existing position -# result = await accounts_service.gateway_client.clmm_add_liquidity( -# connector=request.connector, -# network=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 -# ) -# -# 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") -# -# # Get transaction status from Gateway response -# tx_status = get_transaction_status_from_response(result) -# -# # Extract gas fee from Gateway response -# data = result.get("data", {}) -# gas_fee = data.get("fee") -# gas_token = "SOL" if chain == "solana" else "ETH" if chain == "ethereum" else None -# -# # Store ADD_LIQUIDITY event in database -# try: -# async with db_manager.get_session_context() as session: -# clmm_repo = GatewayCLMMRepository(session) -# -# # Get position to link event -# position = await clmm_repo.get_position_by_address(request.position_address) -# if position: -# event_data = { -# "position_id": position.id, -# "transaction_hash": transaction_hash, -# "event_type": "ADD_LIQUIDITY", -# "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, -# "gas_fee": float(gas_fee) if gas_fee else None, -# "gas_token": gas_token, -# "status": tx_status -# } -# await clmm_repo.create_event(event_data) -# logger.info(f"Recorded CLMM ADD_LIQUIDITY event: {transaction_hash} (status: {tx_status}, gas: {gas_fee} {gas_token})") -# except Exception as db_error: -# logger.error(f"Error recording ADD_LIQUIDITY event: {db_error}", exc_info=True) -# -# return { -# "transaction_hash": transaction_hash, -# "position_address": request.position_address, -# "status": "submitted" -# } -# -# except HTTPException: -# raise -# except ValueError as e: -# raise HTTPException(status_code=400, detail=str(e)) -# except Exception as e: -# logger.error(f"Error adding liquidity to CLMM position: {e}", exc_info=True) -# raise HTTPException(status_code=500, detail=f"Error adding liquidity to CLMM position: {str(e)}") -# -# -# @router.post("/clmm/remove") -# async def remove_liquidity_from_clmm_position( -# request: CLMMRemoveLiquidityRequest, -# accounts_service: AccountsService = Depends(get_accounts_service), -# db_manager: AsyncDatabaseManager = Depends(get_database_manager) -# ): -# """ -# Remove SOME liquidity from a CLMM position (partial removal). -# -# Example: -# connector: 'meteora' -# network: 'solana-mainnet-beta' -# position_address: '...' -# percentage: 50 -# wallet_address: (optional) -# -# Returns: -# Transaction hash -# """ -# try: -# 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) -# -# # Get wallet address -# wallet_address = await accounts_service.gateway_client.get_wallet_address_or_default( -# chain=chain, -# wallet_address=request.wallet_address -# ) -# -# # Remove liquidity -# result = await accounts_service.gateway_client.clmm_remove_liquidity( -# connector=request.connector, -# network=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: -# raise HTTPException(status_code=500, detail="No transaction hash returned from Gateway") -# -# # Get transaction status from Gateway response -# tx_status = get_transaction_status_from_response(result) -# -# # Extract gas fee from Gateway response -# data = result.get("data", {}) -# gas_fee = data.get("fee") -# gas_token = "SOL" if chain == "solana" else "ETH" if chain == "ethereum" else None -# -# # Store REMOVE_LIQUIDITY event in database -# try: -# async with db_manager.get_session_context() as session: -# clmm_repo = GatewayCLMMRepository(session) -# -# # Get position to link event -# position = await clmm_repo.get_position_by_address(request.position_address) -# if position: -# event_data = { -# "position_id": position.id, -# "transaction_hash": transaction_hash, -# "event_type": "REMOVE_LIQUIDITY", -# "percentage": float(request.percentage), -# "gas_fee": float(gas_fee) if gas_fee else None, -# "gas_token": gas_token, -# "status": tx_status -# } -# await clmm_repo.create_event(event_data) -# logger.info(f"Recorded CLMM REMOVE_LIQUIDITY event: {transaction_hash} (status: {tx_status}, gas: {gas_fee} {gas_token})") -# except Exception as db_error: -# logger.error(f"Error recording REMOVE_LIQUIDITY event: {db_error}", exc_info=True) -# -# return { -# "transaction_hash": transaction_hash, -# "position_address": request.position_address, -# "percentage": float(request.percentage), -# "status": "submitted" -# } -# -# except HTTPException: -# raise -# except ValueError as e: -# raise HTTPException(status_code=400, detail=str(e)) -# except Exception as e: -# logger.error(f"Error removing liquidity from CLMM position: {e}", exc_info=True) -# raise HTTPException(status_code=500, detail=f"Error removing liquidity from CLMM position: {str(e)}") -# +@router.post("/clmm/add") +async def add_liquidity_to_clmm_position( + request: CLMMAddLiquidityRequest, + accounts_service: AccountsService = Depends(get_accounts_service), + db_manager: AsyncDatabaseManager = Depends(get_database_manager) +): + """ + Add MORE liquidity to an EXISTING CLMM position. + + Example: + connector: 'meteora' + network: 'solana-mainnet-beta' + position_address: '...' + base_token_amount: 0.5 + quote_token_amount: 50.0 + slippage_pct: 1 + wallet_address: (optional) + + Returns: + Transaction hash + """ + try: + 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) + + # Get wallet address + wallet_address = await accounts_service.gateway_client.get_wallet_address_or_default( + chain=chain, + wallet_address=request.wallet_address + ) + + # Add liquidity to existing position + result = await accounts_service.gateway_client.clmm_add_liquidity( + connector=request.connector, + network=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 + ) + + 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") + + # Get transaction status from Gateway response + tx_status = get_transaction_status_from_response(result) + + # Extract gas fee from Gateway response + data = result.get("data", {}) + gas_fee = data.get("fee") + gas_token = "SOL" if chain == "solana" else "ETH" if chain == "ethereum" else None + + # Store ADD_LIQUIDITY event in database + try: + async with db_manager.get_session_context() as session: + clmm_repo = GatewayCLMMRepository(session) + + # Get position to link event + position = await clmm_repo.get_position_by_address(request.position_address) + if position: + event_data = { + "position_id": position.id, + "transaction_hash": transaction_hash, + "event_type": "ADD_LIQUIDITY", + "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, + "gas_fee": float(gas_fee) if gas_fee else None, + "gas_token": gas_token, + "status": tx_status + } + await clmm_repo.create_event(event_data) + logger.info(f"Recorded CLMM ADD_LIQUIDITY event: {transaction_hash} (status: {tx_status}, gas: {gas_fee} {gas_token})") + except Exception as db_error: + logger.error(f"Error recording ADD_LIQUIDITY event: {db_error}", exc_info=True) + + return { + "transaction_hash": transaction_hash, + "position_address": request.position_address, + "status": "submitted" + } + + except HTTPException: + raise + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"Error adding liquidity to CLMM position: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Error adding liquidity to CLMM position: {str(e)}") + + +@router.post("/clmm/remove") +async def remove_liquidity_from_clmm_position( + request: CLMMRemoveLiquidityRequest, + accounts_service: AccountsService = Depends(get_accounts_service), + db_manager: AsyncDatabaseManager = Depends(get_database_manager) +): + """ + Remove SOME liquidity from a CLMM position (partial removal). + + Example: + connector: 'meteora' + network: 'solana-mainnet-beta' + position_address: '...' + percentage: 50 + wallet_address: (optional) + + Returns: + Transaction hash + """ + try: + 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) + + # Get wallet address + wallet_address = await accounts_service.gateway_client.get_wallet_address_or_default( + chain=chain, + wallet_address=request.wallet_address + ) + + # Remove liquidity + result = await accounts_service.gateway_client.clmm_remove_liquidity( + connector=request.connector, + network=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: + raise HTTPException(status_code=500, detail="No transaction hash returned from Gateway") + + # Get transaction status from Gateway response + tx_status = get_transaction_status_from_response(result) + + # Extract gas fee from Gateway response + data = result.get("data", {}) + gas_fee = data.get("fee") + gas_token = "SOL" if chain == "solana" else "ETH" if chain == "ethereum" else None + + # Store REMOVE_LIQUIDITY event in database + try: + async with db_manager.get_session_context() as session: + clmm_repo = GatewayCLMMRepository(session) + + # Get position to link event + position = await clmm_repo.get_position_by_address(request.position_address) + if position: + event_data = { + "position_id": position.id, + "transaction_hash": transaction_hash, + "event_type": "REMOVE_LIQUIDITY", + "percentage": float(request.percentage), + "gas_fee": float(gas_fee) if gas_fee else None, + "gas_token": gas_token, + "status": tx_status + } + await clmm_repo.create_event(event_data) + logger.info(f"Recorded CLMM REMOVE_LIQUIDITY event: {transaction_hash} (status: {tx_status}, gas: {gas_fee} {gas_token})") + except Exception as db_error: + logger.error(f"Error recording REMOVE_LIQUIDITY event: {db_error}", exc_info=True) + + return { + "transaction_hash": transaction_hash, + "position_address": request.position_address, + "percentage": float(request.percentage), + "status": "submitted" + } + + except HTTPException: + raise + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"Error removing liquidity from CLMM position: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Error removing liquidity from CLMM position: {str(e)}") + @router.post("/clmm/close", response_model=CLMMCollectFeesResponse) async def close_clmm_position( diff --git a/routers/gateway_proxy.py b/routers/gateway_proxy.py index d735c137..a6a0b596 100644 --- a/routers/gateway_proxy.py +++ b/routers/gateway_proxy.py @@ -2,16 +2,16 @@ Gateway Proxy Router Catch-all router that forwards requests to Gateway server unchanged. -Dashboard calls /api/gateway-proxy/* and this router forwards to Gateway at localhost:15888/*. +Dashboard calls /gateway-proxy/* and this router forwards to Gateway at localhost:15888/*. This allows the dashboard to access all Gateway endpoints through the API without needing each endpoint to be explicitly defined. Examples: - GET /api/gateway-proxy/wallet -> GET localhost:15888/wallet - POST /api/gateway-proxy/wallet/add -> POST localhost:15888/wallet/add - GET /api/gateway-proxy/config -> GET localhost:15888/config - GET /api/gateway-proxy/trading/clmm/positions-owned -> GET localhost:15888/trading/clmm/positions-owned + GET /gateway-proxy/wallet -> GET localhost:15888/wallet + POST /gateway-proxy/wallet/add -> POST localhost:15888/wallet/add + GET /gateway-proxy/config -> GET localhost:15888/config + GET /gateway-proxy/trading/clmm/positions-owned -> GET localhost:15888/trading/clmm/positions-owned """ import json @@ -29,24 +29,12 @@ router = APIRouter(tags=["Gateway Proxy"], prefix="/gateway-proxy") -@router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"]) -async def forward_to_gateway( +async def _forward_to_gateway( path: str, request: Request, - accounts_service: AccountsService = Depends(get_accounts_service) + accounts_service: AccountsService ): - """ - Forward request to Gateway server unchanged. - - This catch-all route forwards any request to /api/gateway-proxy/* to the Gateway server. - The request body, headers, and query parameters are passed through unchanged. - The response from Gateway is returned unchanged. - - Examples: - GET /api/gateway-proxy/wallet -> GET localhost:15888/wallet - POST /api/gateway-proxy/wallet/add -> POST localhost:15888/wallet/add - GET /api/gateway-proxy/config -> GET localhost:15888/config - """ + """Internal handler that forwards requests to Gateway.""" gateway_client = accounts_service.gateway_client gateway_url = gateway_client.base_url @@ -113,6 +101,56 @@ async def forward_to_gateway( ) +@router.get("/{path:path}", operation_id="gateway_proxy_get") +async def gateway_proxy_get( + path: str, + request: Request, + accounts_service: AccountsService = Depends(get_accounts_service) +): + """GET request to Gateway. Example: GET /gateway-proxy/wallet""" + return await _forward_to_gateway(path, request, accounts_service) + + +@router.post("/{path:path}", operation_id="gateway_proxy_post") +async def gateway_proxy_post( + path: str, + request: Request, + accounts_service: AccountsService = Depends(get_accounts_service) +): + """POST request to Gateway. Example: POST /gateway-proxy/wallet/add""" + return await _forward_to_gateway(path, request, accounts_service) + + +@router.put("/{path:path}", operation_id="gateway_proxy_put") +async def gateway_proxy_put( + path: str, + request: Request, + accounts_service: AccountsService = Depends(get_accounts_service) +): + """PUT request to Gateway.""" + return await _forward_to_gateway(path, request, accounts_service) + + +@router.delete("/{path:path}", operation_id="gateway_proxy_delete") +async def gateway_proxy_delete( + path: str, + request: Request, + accounts_service: AccountsService = Depends(get_accounts_service) +): + """DELETE request to Gateway.""" + return await _forward_to_gateway(path, request, accounts_service) + + +@router.patch("/{path:path}", operation_id="gateway_proxy_patch") +async def gateway_proxy_patch( + path: str, + request: Request, + accounts_service: AccountsService = Depends(get_accounts_service) +): + """PATCH request to Gateway.""" + return await _forward_to_gateway(path, request, accounts_service) + + # Also expose the root endpoint for health checks @router.get("") async def gateway_root( diff --git a/services/accounts_service.py b/services/accounts_service.py index 1d951017..4726dc19 100644 --- a/services/accounts_service.py +++ b/services/accounts_service.py @@ -1932,8 +1932,8 @@ async def _update_gateway_balances(self, chain_networks: Optional[List[str]] = N logger.debug(f"Chain '{chain}' missing defaultWallet, skipping") continue - # Skip placeholder wallet addresses (e.g., "ethereum-default-wallet", "solana-default-wallet") - if default_wallet.endswith("-default-wallet"): + # Skip placeholder wallet addresses from Gateway templates (e.g., '') + if default_wallet.startswith("<") and default_wallet.endswith(">"): logger.debug(f"Chain '{chain}' has placeholder defaultWallet '{default_wallet}', skipping") continue @@ -2017,13 +2017,14 @@ async def get_gateway_wallets(self) -> List[Dict]: logger.error(f"Error getting Gateway wallets: {e}") raise HTTPException(status_code=500, detail=f"Failed to get wallets: {str(e)}") - async def add_gateway_wallet(self, chain: str, private_key: str) -> Dict: + async def add_gateway_wallet(self, chain: str, private_key: str, set_default: bool = True) -> Dict: """ Add a wallet to Gateway. Gateway handles encryption internally. Args: chain: Blockchain chain (e.g., 'solana', 'ethereum') private_key: Wallet private key + set_default: Set as default wallet for this chain (default: True) Returns: Dictionary with wallet information from Gateway @@ -2032,7 +2033,7 @@ async def add_gateway_wallet(self, chain: str, private_key: str) -> Dict: raise HTTPException(status_code=503, detail="Gateway service is not available") try: - result = await self.gateway_client.add_wallet(chain, private_key, set_default=True) + result = await self.gateway_client.add_wallet(chain, private_key, set_default=set_default) if "error" in result: raise HTTPException(status_code=400, detail=f"Gateway error: {result['error']}") diff --git a/services/bots_orchestrator.py b/services/bots_orchestrator.py index 85622f14..887700af 100644 --- a/services/bots_orchestrator.py +++ b/services/bots_orchestrator.py @@ -1,7 +1,7 @@ import asyncio import logging -from typing import Optional import re +from typing import Optional import docker @@ -220,6 +220,36 @@ async def get_bot_history(self, bot_name, **kwargs): return {"success": True, "data": response} + async def get_bot_lp_history(self, bot_name, **kwargs): + """ + Request bot LP (liquidity provider) history and wait for the response. + This returns LP position updates from RangePositionUpdate records. + """ + if bot_name not in self.active_bots: + logger.warning(f"Bot {bot_name} not found in active bots") + return {"success": False, "message": f"Bot {bot_name} not found"} + + # Create LPHistoryCommandMessage.Request format + data = { + "days": kwargs.get("days", 0), + "verbose": kwargs.get("verbose", False), + "precision": kwargs.get("precision"), + "async_backend": kwargs.get("async_backend", False), + } + + # Use the new RPC method to wait for response + timeout = kwargs.get("timeout", 30.0) # Default 30 second timeout + response = await self.mqtt_manager.publish_command_and_wait(bot_name, "lphistory", data, timeout=timeout) + + if response is None: + return { + "success": False, + "message": f"No response received from {bot_name} within {timeout} seconds", + "timeout": True, + } + + return {"success": True, "data": response} + @staticmethod def determine_controller_performance(controller_reports): """Process controller reports and extract performance and custom_info. diff --git a/services/executor_service.py b/services/executor_service.py index 13b24dc1..63c0dca1 100644 --- a/services/executor_service.py +++ b/services/executor_service.py @@ -26,6 +26,8 @@ from hummingbot.strategy_v2.executors.order_executor.order_executor import OrderExecutor from hummingbot.strategy_v2.executors.position_executor.data_types import PositionExecutorConfig from hummingbot.strategy_v2.executors.position_executor.position_executor import PositionExecutor +from hummingbot.strategy_v2.executors.swap_executor.data_types import SwapExecutorConfig +from hummingbot.strategy_v2.executors.swap_executor.swap_executor import SwapExecutor from hummingbot.strategy_v2.executors.twap_executor.data_types import TWAPExecutorConfig from hummingbot.strategy_v2.executors.twap_executor.twap_executor import TWAPExecutor from hummingbot.strategy_v2.executors.xemm_executor.data_types import XEMMExecutorConfig @@ -82,6 +84,7 @@ class ExecutorService: "xemm_executor": (XEMMExecutor, XEMMExecutorConfig), "order_executor": (OrderExecutor, OrderExecutorConfig), "lp_executor": (LPExecutor, LPExecutorConfig), + "swap_executor": (SwapExecutor, SwapExecutorConfig), } def __init__( @@ -346,15 +349,38 @@ async def create_executor( trading_interface = self._get_trading_interface(account) # Extract connector and trading pair from config + # Note: swap_executor uses 'network' instead of 'connector_name' since it calls Gateway directly connector_name = executor_config.get("connector_name") trading_pair = executor_config.get("trading_pair") - if not connector_name: - raise HTTPException(status_code=400, detail="connector_name is required in executor_config") - if not trading_pair: - raise HTTPException(status_code=400, detail="trading_pair is required in executor_config") - # Ensure connector and market are ready - await trading_interface.add_market(connector_name, trading_pair) + if executor_type == "swap_executor": + # SwapExecutor uses network, not connector_name + network = executor_config.get("network") + if not network: + raise HTTPException(status_code=400, detail="network is required for swap_executor") + if not trading_pair: + raise HTTPException(status_code=400, detail="trading_pair is required in executor_config") + # Use network as connector_name for metadata tracking + connector_name = network + elif executor_type == "lp_executor": + # LPExecutor: trading_pair is optional (resolved from pool_address) + if not connector_name: + raise HTTPException(status_code=400, detail="connector_name is required for lp_executor") + pool_address = executor_config.get("pool_address") + if not pool_address: + raise HTTPException(status_code=400, detail="pool_address is required for lp_executor") + # Ensure connector is ready (trading_pair resolved in executor on_start) + await trading_interface.ensure_connector(connector_name) + # Use pool_address as trading_pair placeholder for metadata if not provided + if not trading_pair: + trading_pair = pool_address + else: + if not connector_name: + raise HTTPException(status_code=400, detail="connector_name is required in executor_config") + if not trading_pair: + raise HTTPException(status_code=400, detail="trading_pair is required in executor_config") + # Ensure connector and market are ready + await trading_interface.add_market(connector_name, trading_pair) # Set timestamp if not provided (required for time-based features like time_limit) if "timestamp" not in executor_config or executor_config["timestamp"] is None: diff --git a/services/gateway_client.py b/services/gateway_client.py index 278448a1..b62aec07 100644 --- a/services/gateway_client.py +++ b/services/gateway_client.py @@ -202,6 +202,13 @@ async def remove_wallet(self, chain: str, address: str) -> Dict: "address": address }) + async def set_default_wallet(self, chain: str, address: str) -> Dict: + """Set the default wallet for a chain in Gateway""" + return await self._request("POST", "wallet/setDefault", json={ + "chain": chain, + "address": address + }) + async def get_balances(self, chain: str, network: str, address: str, tokens: Optional[List[str]] = None) -> Dict: """Get token balances for a wallet""" return await self._request("POST", f"chains/{chain}/balances", json={ diff --git a/utils/mqtt_manager.py b/utils/mqtt_manager.py index 3495eadb..63286c87 100644 --- a/utils/mqtt_manager.py +++ b/utils/mqtt_manager.py @@ -151,7 +151,7 @@ async def _process_message(self, message): await self._handle_command_response(bot_id, channel, data) elif channel.startswith("external/event/"): await self._handle_external_event(bot_id, channel, data) - elif channel in ["history", "start", "stop", "config", "import_strategy"]: + elif channel in ["history", "lphistory", "start", "stop", "config", "import_strategy"]: # These are command channels - responses should come on response/* topics logger.debug(f"Command channel '{channel}' for bot {bot_id} - waiting for response") else: