diff --git a/main.py b/main.py index 54275db4..963b800e 100644 --- a/main.py +++ b/main.py @@ -50,6 +50,7 @@ def patched_save_to_yml(yml_path, cm): docker, executors, gateway, + gateway_amm, gateway_clmm, gateway_swap, market_data, @@ -431,6 +432,7 @@ def auth_user( app.include_router(trading.router, dependencies=[Depends(auth_user)]) app.include_router(gateway_swap.router, dependencies=[Depends(auth_user)]) app.include_router(gateway_clmm.router, dependencies=[Depends(auth_user)]) +app.include_router(gateway_amm.router, dependencies=[Depends(auth_user)]) app.include_router(bot_orchestration.router, dependencies=[Depends(auth_user)]) app.include_router(controllers.router, dependencies=[Depends(auth_user)]) app.include_router(scripts.router, dependencies=[Depends(auth_user)]) diff --git a/models/__init__.py b/models/__init__.py index eb3674d8..8ecd9eb3 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -87,8 +87,22 @@ UpdateApiKeysRequest, ) -# Gateway Trading models (Swap + CLMM only, AMM removed) -from .gateway_trading import ( # Swap models; CLMM models; Pool info models; Pool listing models +# Gateway Trading models (Swap + CLMM + AMM) +from .gateway_trading import ( # Swap models; CLMM models; AMM models; Pool info models; Pool listing models + AMMAddLiquidityRequest, + AMMCreatePoolRequest, + AMMCreatePoolResponse, + AMMExecuteSwapRequest, + AMMPoolInfoResponse, + AMMPositionDetail, + AMMPositionInfoResponse, + AMMPositionsOwnedRequest, + AMMQuoteLiquidityRequest, + AMMQuoteLiquidityResponse, + AMMQuoteSwapRequest, + AMMQuoteSwapResponse, + AMMRemoveLiquidityRequest, + AMMTransactionResponse, CLMMAddLiquidityRequest, CLMMClosePositionRequest, CLMMCollectFeesRequest, @@ -302,6 +316,21 @@ # Gateway Trading models "SwapQuoteRequest", "SwapQuoteResponse", + # AMM models + "AMMPoolInfoResponse", + "AMMPositionDetail", + "AMMPositionInfoResponse", + "AMMQuoteSwapRequest", + "AMMQuoteSwapResponse", + "AMMExecuteSwapRequest", + "AMMTransactionResponse", + "AMMQuoteLiquidityRequest", + "AMMQuoteLiquidityResponse", + "AMMAddLiquidityRequest", + "AMMRemoveLiquidityRequest", + "AMMCreatePoolRequest", + "AMMCreatePoolResponse", + "AMMPositionsOwnedRequest", "SwapExecuteRequest", "SwapExecuteResponse", "CLMMOpenPositionRequest", diff --git a/models/gateway_trading.py b/models/gateway_trading.py index bef49e1b..c9cb9c48 100644 --- a/models/gateway_trading.py +++ b/models/gateway_trading.py @@ -235,6 +235,183 @@ class CLMMPoolInfoResponse(BaseModel): } +# ============================================ +# AMM Liquidity Models (Meteora DAMM v2, Raydium CPMM, Uniswap/Pancakeswap V2) +# ============================================ +# Re-added deliberately as a separate surface from CLMM. Unlike classic fungible-LP AMMs, +# Meteora DAMM v2 positions are NFTs (a wallet may hold several per pool), so the AMM routes +# are position-addressed: remove requires position_address (meteora), add takes it optionally +# (omit = new position), position-info returns a positions[] breakdown, and positions-owned +# lists all of a wallet's positions. Fungible-LP AMMs ignore position_address. + +class AMMPoolInfoResponse(BaseModel): + """Response with AMM pool information (constant-product / DAMM v2).""" + address: str = Field(description="Pool address") + base_token_address: str = Field(alias="baseTokenAddress", description="Base token contract address") + quote_token_address: str = Field(alias="quoteTokenAddress", description="Quote token contract address") + fee_pct: Decimal = Field(alias="feePct", description="Pool base fee percentage") + price: Decimal = Field(description="Current pool price (quote per base)") + base_token_amount: Decimal = Field(alias="baseTokenAmount", description="Total base token liquidity") + quote_token_amount: Decimal = Field(alias="quoteTokenAmount", description="Total quote token liquidity") + + model_config = {"populate_by_name": True} + + +class AMMPositionDetail(BaseModel): + """Per-position breakdown entry (one NFT position). Non-fungible-LP AMMs only.""" + position_address: str = Field(alias="positionAddress", description="Individual position (NFT) address") + lp_token_amount: Decimal = Field(alias="lpTokenAmount", description="Liquidity held by this position (LP units)") + base_token_amount: Decimal = Field(alias="baseTokenAmount", description="Base token amount in this position") + quote_token_amount: Decimal = Field(alias="quoteTokenAmount", description="Quote token amount in this position") + + model_config = {"populate_by_name": True} + + +class AMMPositionInfoResponse(BaseModel): + """Wallet's aggregate liquidity in an AMM pool, plus a per-position breakdown (DAMM v2).""" + pool_address: str = Field(alias="poolAddress", description="Pool address") + wallet_address: str = Field(alias="walletAddress", description="Wallet address") + base_token_address: str = Field(alias="baseTokenAddress", description="Base token contract address") + quote_token_address: str = Field(alias="quoteTokenAddress", description="Quote token contract address") + lp_token_amount: Decimal = Field(alias="lpTokenAmount", description="Aggregate LP units across positions") + base_token_amount: Decimal = Field(alias="baseTokenAmount", description="Aggregate base token amount") + quote_token_amount: Decimal = Field(alias="quoteTokenAmount", description="Aggregate quote token amount") + price: Decimal = Field(description="Current pool price (quote per base)") + # Per-position breakdown; populated by Meteora DAMM v2, omitted by fungible-LP AMMs. + positions: Optional[List[AMMPositionDetail]] = Field(default=None, description="Per-NFT position breakdown") + + model_config = {"populate_by_name": True} + + +class AMMQuoteSwapRequest(BaseModel): + """Request to quote a swap against a specific AMM pool.""" + connector: str = Field(description="AMM connector (e.g., 'meteora', 'raydium', 'uniswap')") + network: str = Field(description="Network ID in 'chain-network' format (e.g., 'solana-mainnet-beta')") + pool_address: str = Field(description="Pool contract address") + base_token: str = Field(description="Token that defines the swap direction (symbol or address)") + side: str = Field(description="Trade direction: BUY or SELL") + amount: Decimal = Field(description="Amount to swap (of base for SELL, of base to receive for BUY)") + slippage_pct: Optional[Decimal] = Field(default=None, description="Maximum slippage percentage") + + +class AMMQuoteSwapResponse(BaseModel): + """Response with an AMM swap quote.""" + pool_address: str = Field(alias="poolAddress", description="Pool address") + token_in: str = Field(alias="tokenIn", description="Input token address") + token_out: str = Field(alias="tokenOut", description="Output token address") + amount_in: Decimal = Field(alias="amountIn", description="Input amount") + amount_out: Decimal = Field(alias="amountOut", description="Output amount") + price: Decimal = Field(description="Execution price") + min_amount_out: Decimal = Field(alias="minAmountOut", description="Minimum output after slippage") + max_amount_in: Decimal = Field(alias="maxAmountIn", description="Maximum input after slippage") + price_impact_pct: Decimal = Field(alias="priceImpactPct", description="Price impact percentage") + slippage_pct: Optional[Decimal] = Field(default=None, alias="slippagePct", description="Slippage percentage used") + + model_config = {"populate_by_name": True} + + +class AMMExecuteSwapRequest(BaseModel): + """Request to execute a swap against a specific AMM pool.""" + connector: str = Field(description="AMM connector (e.g., 'meteora', 'raydium', 'uniswap')") + network: str = Field(description="Network ID in 'chain-network' format (e.g., 'solana-mainnet-beta')") + pool_address: str = Field(description="Pool contract address") + base_token: str = Field(description="Token that defines the swap direction (symbol or address)") + side: str = Field(description="Trade direction: BUY or SELL") + amount: Decimal = Field(description="Amount to swap") + slippage_pct: Optional[Decimal] = Field(default=1.0, description="Maximum slippage percentage (default: 1.0)") + wallet_address: Optional[str] = Field(default=None, description="Wallet address (optional, uses default)") + + +class AMMTransactionResponse(BaseModel): + """Chain-neutral write response. `signature` holds the tx signature (Solana) or tx hash (EVM).""" + signature: str = Field(description="Transaction signature (Solana) or transaction hash (EVM)") + status: int = Field(description="TransactionStatus enum value from Gateway") + data: Optional[Dict[str, Any]] = Field(default=None, description="Connector-specific confirmed-tx details") + + model_config = {"populate_by_name": True} + + +class AMMQuoteLiquidityRequest(BaseModel): + """Request to quote a two-sided liquidity deposit.""" + connector: str = Field(description="AMM connector (e.g., 'meteora', 'raydium', 'uniswap')") + network: str = Field(description="Network ID in 'chain-network' format (e.g., 'solana-mainnet-beta')") + pool_address: str = Field(description="Pool contract address") + base_token_amount: Decimal = Field(description="Amount of base token to deposit") + quote_token_amount: Decimal = Field(description="Amount of quote token to deposit") + slippage_pct: Optional[Decimal] = Field(default=None, description="Maximum slippage percentage") + + +class AMMQuoteLiquidityResponse(BaseModel): + """Response with a two-sided deposit quote.""" + base_limited: bool = Field(alias="baseLimited", description="Whether the base side is the limiting side") + base_token_amount: Decimal = Field(alias="baseTokenAmount", description="Base token amount to deposit") + quote_token_amount: Decimal = Field(alias="quoteTokenAmount", description="Quote token amount to deposit") + base_token_amount_max: Decimal = Field(alias="baseTokenAmountMax", description="Max base token amount") + quote_token_amount_max: Decimal = Field(alias="quoteTokenAmountMax", description="Max quote token amount") + + model_config = {"populate_by_name": True} + + +class AMMAddLiquidityRequest(BaseModel): + """Request to add two-sided liquidity to an AMM pool.""" + connector: str = Field(description="AMM connector (e.g., 'meteora', 'raydium', 'uniswap')") + network: str = Field(description="Network ID in 'chain-network' format (e.g., 'solana-mainnet-beta')") + pool_address: str = Field(description="Pool contract address") + base_token_amount: Decimal = Field(description="Amount of base token to add") + quote_token_amount: Decimal = Field(description="Amount of quote token to add") + slippage_pct: Optional[Decimal] = Field(default=1.0, description="Maximum slippage percentage (default: 1.0)") + wallet_address: Optional[str] = Field(default=None, description="Wallet address (optional, uses default)") + # Meteora DAMM v2: add to this specific NFT position; omit to open a NEW position. Ignored by fungible-LP AMMs. + position_address: Optional[str] = Field(default=None, description="Meteora position to add to (omit = new position)") + + +class AMMRemoveLiquidityRequest(BaseModel): + """Request to remove liquidity from an AMM pool.""" + connector: str = Field(description="AMM connector (e.g., 'meteora', 'raydium', 'uniswap')") + network: str = Field(description="Network ID in 'chain-network' format (e.g., 'solana-mainnet-beta')") + pool_address: str = Field(description="Pool contract address") + percentage_to_remove: Decimal = Field(description="Percentage of liquidity to remove (0-100)") + slippage_pct: Optional[Decimal] = Field(default=None, description="Maximum slippage percentage") + wallet_address: Optional[str] = Field(default=None, description="Wallet address (optional, uses default)") + # Required for meteora (DAMM v2 positions are NFTs). Ignored by fungible-LP AMMs. + position_address: Optional[str] = Field(default=None, description="Meteora position to remove from (required for meteora)") + + +class AMMCreatePoolRequest(BaseModel): + """Request to create and seed a new AMM pool.""" + connector: str = Field(description="AMM connector (e.g., 'meteora', 'raydium', 'uniswap')") + network: str = Field(description="Network ID in 'chain-network' format (e.g., 'solana-mainnet-beta')") + base_token: str = Field(description="Base token symbol or address (becomes the pool base)") + quote_token: str = Field(description="Quote token symbol or address (becomes the pool quote)") + base_token_amount: Decimal = Field(description="Amount of base token to seed the pool with") + quote_token_amount: Optional[Decimal] = Field(default=None, description="Amount of quote to seed (sets price if given)") + initial_price: Optional[Decimal] = Field(default=None, description="Initial price (quote per base); overrides quote amount") + wallet_address: Optional[str] = Field(default=None, description="Wallet address (optional, uses default)") + # Connector-specific create-pool extras (only consumed by their owning connector): + config_address: Optional[str] = Field(default=None, description="Meteora DAMM v2 config account (required for meteora)") + fee_config_index: Optional[int] = Field(default=None, description="Raydium CPMM fee config index (optional)") + gas_price: Optional[Decimal] = Field(default=None, description="Uniswap (EVM) gas price in gwei (optional)") + max_gas: Optional[int] = Field(default=None, description="Uniswap (EVM) max gas limit (optional)") + + +class AMMCreatePoolResponse(BaseModel): + """Response after creating an AMM pool.""" + signature: str = Field(description="Transaction signature (Solana) or transaction hash (EVM)") + status: int = Field(description="TransactionStatus enum value from Gateway") + pool_address: str = Field(alias="poolAddress", description="Address of the newly created pool") + price: Optional[Decimal] = Field(default=None, description="Initial price the pool was seeded at (quote per base)") + data: Optional[Dict[str, Any]] = Field(default=None, description="Connector-specific confirmed-tx details") + + model_config = {"populate_by_name": True} + + +class AMMPositionsOwnedRequest(BaseModel): + """Request to list all of a wallet's AMM positions across pools (Meteora only).""" + connector: str = Field(description="AMM connector (meteora only; fungible-LP AMMs rejected)") + network: str = Field(description="Network ID in 'chain-network' format (e.g., 'solana-mainnet-beta')") + wallet_address: Optional[str] = Field(default=None, description="Wallet address (optional, uses default)") + + # ============================================ # Pool Information Models # ============================================ diff --git a/routers/gateway_amm.py b/routers/gateway_amm.py new file mode 100644 index 00000000..42d9d0d9 --- /dev/null +++ b/routers/gateway_amm.py @@ -0,0 +1,319 @@ +""" +Gateway AMM Router - Handles DEX AMM liquidity operations via Hummingbot Gateway. + +Supports AMM connectors (Meteora DAMM v2, Raydium CPMM, Uniswap/Pancakeswap V2). This is a +deliberately separate surface from CLMM: AMM was previously removed from hummingbot-api and is +re-added here to expose Gateway's standardized /trading/amm/* routes. + +Stateless by design (no position persistence) — the caller/agent holds position state. Meteora +DAMM v2 positions are NFTs, so the routes are position-addressed: remove requires position_address, +add takes it optionally (omit = new position), position-info returns a positions[] breakdown, and +positions-owned lists all of a wallet's positions. Fungible-LP AMMs ignore position_address and +Gateway rejects positions-owned for them with a 400, surfaced here as-is. +""" +import logging +from typing import List, Optional + +from fastapi import APIRouter, Depends, HTTPException + +from deps import get_accounts_service +from models import ( + AMMAddLiquidityRequest, + AMMCreatePoolRequest, + AMMCreatePoolResponse, + AMMExecuteSwapRequest, + AMMPoolInfoResponse, + AMMPositionInfoResponse, + AMMPositionsOwnedRequest, + AMMQuoteLiquidityRequest, + AMMQuoteLiquidityResponse, + AMMQuoteSwapRequest, + AMMQuoteSwapResponse, + AMMRemoveLiquidityRequest, + AMMTransactionResponse, +) +from services.accounts_service import AccountsService +from services.gateway_client import GatewayError, check_gateway_error + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["Gateway AMM"], prefix="/gateway") + + +async def _require_gateway(accounts_service: AccountsService) -> None: + if not await accounts_service.gateway_client.ping(): + raise HTTPException(status_code=503, detail="Gateway service is not available") + + +async def _resolve_wallet(accounts_service: AccountsService, network: str, wallet_address) -> str: + chain, _ = accounts_service.gateway_client.parse_network_id(network) + return await accounts_service.gateway_client.get_wallet_address_or_default( + chain=chain, wallet_address=wallet_address + ) + + +# ----------------------------- Reads ----------------------------- + +@router.get("/amm/pool-info", response_model=AMMPoolInfoResponse, response_model_by_alias=False) +async def get_amm_pool_info( + connector: str, + network: str, + pool_address: str, + accounts_service: AccountsService = Depends(get_accounts_service), +): + """Get AMM pool information (reserves, price, base fee) by pool address.""" + try: + await _require_gateway(accounts_service) + result = check_gateway_error(await accounts_service.gateway_client.amm_pool_info( + connector=connector, chain_network=network, pool_address=pool_address, + )) + return AMMPoolInfoResponse(**result) + except HTTPException: + raise + except GatewayError as e: + raise HTTPException(status_code=e.status, detail=f"Gateway error getting AMM pool info: {e}") + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"Error getting AMM pool info: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Error getting AMM pool info: {str(e)}") + + +@router.get("/amm/position-info", response_model=AMMPositionInfoResponse, response_model_by_alias=False) +async def get_amm_position_info( + connector: str, + network: str, + pool_address: str, + wallet_address: Optional[str] = None, + accounts_service: AccountsService = Depends(get_accounts_service), +): + """Get a wallet's aggregate liquidity in an AMM pool plus a per-position breakdown (DAMM v2).""" + try: + await _require_gateway(accounts_service) + wallet_address = await _resolve_wallet(accounts_service, network, wallet_address) + result = check_gateway_error(await accounts_service.gateway_client.amm_position_info( + connector=connector, chain_network=network, pool_address=pool_address, + wallet_address=wallet_address, + )) + return AMMPositionInfoResponse(**result) + except HTTPException: + raise + except GatewayError as e: + raise HTTPException(status_code=e.status, detail=f"Gateway error getting AMM position info: {e}") + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"Error getting AMM position info: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Error getting AMM position info: {str(e)}") + + +@router.post("/amm/positions-owned", response_model=List[AMMPositionInfoResponse], response_model_by_alias=False) +async def get_amm_positions_owned( + request: AMMPositionsOwnedRequest, + accounts_service: AccountsService = Depends(get_accounts_service), +): + """ + List all of a wallet's AMM positions across pools (Meteora DAMM v2 only). + + Fungible-LP AMMs (raydium, uniswap, pancakeswap) have no enumerable positions; Gateway rejects + them with a 400, surfaced here unchanged. Use position-info with a specific pool address instead. + """ + try: + await _require_gateway(accounts_service) + wallet_address = await _resolve_wallet(accounts_service, request.network, request.wallet_address) + result = check_gateway_error(await accounts_service.gateway_client.amm_positions_owned( + connector=request.connector, chain_network=request.network, wallet_address=wallet_address, + )) + positions = result if isinstance(result, list) else [] + return [AMMPositionInfoResponse(**pos) for pos in positions] + except HTTPException: + raise + except GatewayError as e: + raise HTTPException(status_code=e.status, detail=f"Gateway error getting AMM positions owned: {e}") + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"Error getting AMM positions owned: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Error getting AMM positions owned: {str(e)}") + + +@router.post("/amm/quote-swap", response_model=AMMQuoteSwapResponse, response_model_by_alias=False) +async def quote_amm_swap( + request: AMMQuoteSwapRequest, + accounts_service: AccountsService = Depends(get_accounts_service), +): + """Quote a swap against a specific AMM pool (pool-scoped, not router).""" + try: + await _require_gateway(accounts_service) + result = check_gateway_error(await accounts_service.gateway_client.amm_quote_swap( + connector=request.connector, chain_network=request.network, pool_address=request.pool_address, + base_token=request.base_token, side=request.side, amount=float(request.amount), + slippage_pct=float(request.slippage_pct) if request.slippage_pct is not None else None, + )) + return AMMQuoteSwapResponse(**result) + except HTTPException: + raise + except GatewayError as e: + raise HTTPException(status_code=e.status, detail=f"Gateway error quoting AMM swap: {e}") + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"Error quoting AMM swap: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Error quoting AMM swap: {str(e)}") + + +@router.post("/amm/quote-liquidity", response_model=AMMQuoteLiquidityResponse, response_model_by_alias=False) +async def quote_amm_liquidity( + request: AMMQuoteLiquidityRequest, + accounts_service: AccountsService = Depends(get_accounts_service), +): + """Quote a two-sided liquidity deposit.""" + try: + await _require_gateway(accounts_service) + result = check_gateway_error(await accounts_service.gateway_client.amm_quote_liquidity( + connector=request.connector, chain_network=request.network, pool_address=request.pool_address, + base_token_amount=float(request.base_token_amount), quote_token_amount=float(request.quote_token_amount), + slippage_pct=float(request.slippage_pct) if request.slippage_pct is not None else None, + )) + return AMMQuoteLiquidityResponse(**result) + except HTTPException: + raise + except GatewayError as e: + raise HTTPException(status_code=e.status, detail=f"Gateway error quoting AMM liquidity: {e}") + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"Error quoting AMM liquidity: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Error quoting AMM liquidity: {str(e)}") + + +# ----------------------------- Writes ----------------------------- + +@router.post("/amm/execute-swap", response_model=AMMTransactionResponse) +async def execute_amm_swap( + request: AMMExecuteSwapRequest, + accounts_service: AccountsService = Depends(get_accounts_service), +): + """Execute a swap against a specific AMM pool.""" + try: + await _require_gateway(accounts_service) + wallet_address = await _resolve_wallet(accounts_service, request.network, request.wallet_address) + result = check_gateway_error(await accounts_service.gateway_client.amm_execute_swap( + connector=request.connector, chain_network=request.network, wallet_address=wallet_address, + pool_address=request.pool_address, base_token=request.base_token, side=request.side, + amount=float(request.amount), + slippage_pct=float(request.slippage_pct) if request.slippage_pct is not None else None, + )) + return AMMTransactionResponse(**result) + except HTTPException: + raise + except GatewayError as e: + raise HTTPException(status_code=e.status, detail=f"Gateway error executing AMM swap: {e}") + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"Error executing AMM swap: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Error executing AMM swap: {str(e)}") + + +@router.post("/amm/add-liquidity", response_model=AMMTransactionResponse) +async def add_amm_liquidity( + request: AMMAddLiquidityRequest, + accounts_service: AccountsService = Depends(get_accounts_service), +): + """ + Add two-sided liquidity to an AMM pool. + + Meteora DAMM v2: pass position_address to add to that NFT position; omit it to open a new one. + Fungible-LP AMMs ignore position_address. + """ + try: + await _require_gateway(accounts_service) + wallet_address = await _resolve_wallet(accounts_service, request.network, request.wallet_address) + result = check_gateway_error(await accounts_service.gateway_client.amm_add_liquidity( + connector=request.connector, chain_network=request.network, wallet_address=wallet_address, + pool_address=request.pool_address, base_token_amount=float(request.base_token_amount), + quote_token_amount=float(request.quote_token_amount), + slippage_pct=float(request.slippage_pct) if request.slippage_pct is not None else None, + position_address=request.position_address, + )) + return AMMTransactionResponse(**result) + except HTTPException: + raise + except GatewayError as e: + raise HTTPException(status_code=e.status, detail=f"Gateway error adding AMM liquidity: {e}") + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"Error adding AMM liquidity: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Error adding AMM liquidity: {str(e)}") + + +@router.post("/amm/remove-liquidity", response_model=AMMTransactionResponse) +async def remove_amm_liquidity( + request: AMMRemoveLiquidityRequest, + accounts_service: AccountsService = Depends(get_accounts_service), +): + """ + Remove liquidity from an AMM pool. + + Meteora DAMM v2 requires position_address (positions are NFTs); Gateway rejects a missing one + with a 400, surfaced here unchanged, so "remove 100%" is a true exit of the named position. + """ + try: + await _require_gateway(accounts_service) + wallet_address = await _resolve_wallet(accounts_service, request.network, request.wallet_address) + result = check_gateway_error(await accounts_service.gateway_client.amm_remove_liquidity( + connector=request.connector, chain_network=request.network, wallet_address=wallet_address, + pool_address=request.pool_address, percentage_to_remove=float(request.percentage_to_remove), + slippage_pct=float(request.slippage_pct) if request.slippage_pct is not None else None, + position_address=request.position_address, + )) + return AMMTransactionResponse(**result) + except HTTPException: + raise + except GatewayError as e: + raise HTTPException(status_code=e.status, detail=f"Gateway error removing AMM liquidity: {e}") + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"Error removing AMM liquidity: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Error removing AMM liquidity: {str(e)}") + + +@router.post("/amm/create-pool", response_model=AMMCreatePoolResponse, response_model_by_alias=False) +async def create_amm_pool( + request: AMMCreatePoolRequest, + accounts_service: AccountsService = Depends(get_accounts_service), +): + """ + Create and seed a new AMM pool. + + Seed price priority: initial_price → quote_token_amount ratio → live market price (anti-snipe). + Connector extras are sent only when provided (config_address for meteora, fee_config_index for + raydium, gas_price/max_gas for uniswap). + """ + try: + await _require_gateway(accounts_service) + wallet_address = await _resolve_wallet(accounts_service, request.network, request.wallet_address) + result = check_gateway_error(await accounts_service.gateway_client.amm_create_pool( + connector=request.connector, chain_network=request.network, wallet_address=wallet_address, + base_token=request.base_token, quote_token=request.quote_token, + base_token_amount=float(request.base_token_amount), + quote_token_amount=float(request.quote_token_amount) if request.quote_token_amount is not None else None, + initial_price=float(request.initial_price) if request.initial_price is not None else None, + config_address=request.config_address, + fee_config_index=request.fee_config_index, + gas_price=float(request.gas_price) if request.gas_price is not None else None, + max_gas=request.max_gas, + )) + return AMMCreatePoolResponse(**result) + except HTTPException: + raise + except GatewayError as e: + raise HTTPException(status_code=e.status, detail=f"Gateway error creating AMM pool: {e}") + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"Error creating AMM pool: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Error creating AMM pool: {str(e)}") diff --git a/services/gateway_client.py b/services/gateway_client.py index d354039d..cc15d8d1 100644 --- a/services/gateway_client.py +++ b/services/gateway_client.py @@ -712,6 +712,201 @@ async def clmm_fetch_pools( return await self._request("GET", f"connectors/{connector}/clmm/fetch-pools", params=params) + # ============================================ + # AMM Liquidity (Meteora DAMM v2, Raydium CPMM, Uniswap/Pancakeswap V2) + # ============================================ + # All go through the unified trading/amm/* surface (camelCase body/query with connector + + # chainNetwork). Meteora DAMM v2 positions are NFTs, so remove requires positionAddress and + # add takes it optionally; fungible-LP AMMs ignore it. positions-owned is meteora-only. + + async def amm_pool_info(self, connector: str, chain_network: str, pool_address: str) -> Dict: + """Get AMM pool information (reserves, price, base fee).""" + return await self._request("GET", "trading/amm/pool-info", params={ + "connector": connector, + "chainNetwork": chain_network, + "poolAddress": pool_address, + }) + + async def amm_position_info( + self, connector: str, chain_network: str, pool_address: str, wallet_address: str + ) -> Dict: + """Get a wallet's aggregate liquidity in an AMM pool plus a per-position breakdown (DAMM v2).""" + return await self._request("GET", "trading/amm/position-info", params={ + "connector": connector, + "chainNetwork": chain_network, + "poolAddress": pool_address, + "walletAddress": wallet_address, + }) + + async def amm_positions_owned( + self, connector: str, chain_network: str, wallet_address: str + ) -> List[Dict]: + """List all of a wallet's AMM positions across pools (meteora only; fungible-LP → Gateway 400).""" + return await self._request("GET", "trading/amm/positions-owned", params={ + "connector": connector, + "chainNetwork": chain_network, + "walletAddress": wallet_address, + }) + + async def amm_quote_swap( + self, + connector: str, + chain_network: str, + pool_address: str, + base_token: str, + side: str, + amount: float, + slippage_pct: Optional[float] = None, + ) -> Dict: + """Quote a swap against a specific AMM pool.""" + params = { + "connector": connector, + "chainNetwork": chain_network, + "poolAddress": pool_address, + "baseToken": base_token, + "side": side, + "amount": amount, + } + if slippage_pct is not None: + params["slippagePct"] = slippage_pct + return await self._request("GET", "trading/amm/quote-swap", params=params) + + async def amm_execute_swap( + self, + connector: str, + chain_network: str, + wallet_address: str, + pool_address: str, + base_token: str, + side: str, + amount: float, + slippage_pct: Optional[float] = None, + ) -> Dict: + """Execute a swap against a specific AMM pool.""" + payload = { + "connector": connector, + "chainNetwork": chain_network, + "walletAddress": wallet_address, + "poolAddress": pool_address, + "baseToken": base_token, + "side": side, + "amount": amount, + } + if slippage_pct is not None: + payload["slippagePct"] = slippage_pct + return await self._request("POST", "trading/amm/execute-swap", json=payload) + + async def amm_quote_liquidity( + self, + connector: str, + chain_network: str, + pool_address: str, + base_token_amount: float, + quote_token_amount: float, + slippage_pct: Optional[float] = None, + ) -> Dict: + """Quote a two-sided liquidity deposit.""" + payload = { + "connector": connector, + "chainNetwork": chain_network, + "poolAddress": pool_address, + "baseTokenAmount": base_token_amount, + "quoteTokenAmount": quote_token_amount, + } + if slippage_pct is not None: + payload["slippagePct"] = slippage_pct + return await self._request("GET", "trading/amm/quote-liquidity", params=payload) + + async def amm_add_liquidity( + self, + connector: str, + chain_network: str, + wallet_address: str, + pool_address: str, + base_token_amount: float, + quote_token_amount: float, + slippage_pct: Optional[float] = None, + position_address: Optional[str] = None, + ) -> Dict: + """Add two-sided liquidity. For meteora, position_address adds to that NFT position (omit = new).""" + payload = { + "connector": connector, + "chainNetwork": chain_network, + "walletAddress": wallet_address, + "poolAddress": pool_address, + "baseTokenAmount": base_token_amount, + "quoteTokenAmount": quote_token_amount, + } + if slippage_pct is not None: + payload["slippagePct"] = slippage_pct + if position_address is not None: + payload["positionAddress"] = position_address + return await self._request("POST", "trading/amm/add-liquidity", json=payload) + + async def amm_remove_liquidity( + self, + connector: str, + chain_network: str, + wallet_address: str, + pool_address: str, + percentage_to_remove: float, + slippage_pct: Optional[float] = None, + position_address: Optional[str] = None, + ) -> Dict: + """Remove liquidity. Gateway requires position_address for meteora (DAMM v2 NFT positions).""" + payload = { + "connector": connector, + "chainNetwork": chain_network, + "walletAddress": wallet_address, + "poolAddress": pool_address, + "percentageToRemove": percentage_to_remove, + } + if slippage_pct is not None: + payload["slippagePct"] = slippage_pct + if position_address is not None: + payload["positionAddress"] = position_address + return await self._request("POST", "trading/amm/remove-liquidity", json=payload) + + async def amm_create_pool( + self, + connector: str, + chain_network: str, + wallet_address: str, + base_token: str, + quote_token: str, + base_token_amount: float, + quote_token_amount: Optional[float] = None, + initial_price: Optional[float] = None, + config_address: Optional[str] = None, + fee_config_index: Optional[int] = None, + gas_price: Optional[float] = None, + max_gas: Optional[int] = None, + ) -> Dict: + """Create and seed a new AMM pool. Connector extras are sent only when provided.""" + payload = { + "connector": connector, + "chainNetwork": chain_network, + "walletAddress": wallet_address, + "baseToken": base_token, + "quoteToken": quote_token, + "baseTokenAmount": base_token_amount, + } + # Seed price: at most one of quoteTokenAmount / initialPrice; Gateway falls back to market price. + if quote_token_amount is not None: + payload["quoteTokenAmount"] = quote_token_amount + if initial_price is not None: + payload["initialPrice"] = initial_price + # Connector-specific extras (each consumed only by its owning connector): + if config_address is not None: + payload["configAddress"] = config_address + if fee_config_index is not None: + payload["feeConfigIndex"] = fee_config_index + if gas_price is not None: + payload["gasPrice"] = gas_price + if max_gas is not None: + payload["maxGas"] = max_gas + return await self._request("POST", "trading/amm/create-pool", json=payload) + # ============================================ # Transaction Polling # ============================================ diff --git a/test/test_gateway_client_contract.py b/test/test_gateway_client_contract.py index 5faa3182..38d2a803 100644 --- a/test/test_gateway_client_contract.py +++ b/test/test_gateway_client_contract.py @@ -236,3 +236,143 @@ def test_check_gateway_error_ignores_legit_error_fields(): only the exact {'error','status'} shape is the client's HTTP-error marker.""" poll = {"txStatus": -1, "error": "SLIPPAGE_EXCEEDED (0x1771)", "signature": "abc", "fee": 0.1} assert check_gateway_error(poll) is poll + + +# ============================================ +# AMM paths and payloads (unified /trading/amm) +# ============================================ +# Pin the amm_* client methods to Gateway's /trading/amm/* route table (verified live on +# 2026-08-05, hummingbot/gateway feat/meteora-damm-v2): camelCase keys, connector + chainNetwork, +# position-addressing for meteora, and per-connector create-pool extras with unset optionals omitted. + +NET = "solana-mainnet-beta" +WALLET = "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5" +POOL = "Bv65dPQKpUo7vRELhEGBkkm5wq9J3MvKGyUj8WxYtunM" + + +@pytest.mark.asyncio +async def test_amm_pool_info_path(client_and_calls): + client, calls = client_and_calls + await client.amm_pool_info(connector="meteora", chain_network=NET, pool_address=POOL) + c = calls[0] + assert (c["method"], c["path"]) == ("GET", "trading/amm/pool-info") + assert c["params"] == {"connector": "meteora", "chainNetwork": NET, "poolAddress": POOL} + + +@pytest.mark.asyncio +async def test_amm_position_info_path(client_and_calls): + client, calls = client_and_calls + await client.amm_position_info(connector="meteora", chain_network=NET, pool_address=POOL, wallet_address=WALLET) + c = calls[0] + assert (c["method"], c["path"]) == ("GET", "trading/amm/position-info") + assert c["params"] == {"connector": "meteora", "chainNetwork": NET, "poolAddress": POOL, "walletAddress": WALLET} + + +@pytest.mark.asyncio +async def test_amm_positions_owned_path(client_and_calls): + client, calls = client_and_calls + await client.amm_positions_owned(connector="meteora", chain_network=NET, wallet_address=WALLET) + c = calls[0] + assert (c["method"], c["path"]) == ("GET", "trading/amm/positions-owned") + assert c["params"] == {"connector": "meteora", "chainNetwork": NET, "walletAddress": WALLET} + + +@pytest.mark.asyncio +async def test_amm_quote_swap_path_and_slippage_omitted(client_and_calls): + client, calls = client_and_calls + await client.amm_quote_swap(connector="raydium", chain_network=NET, pool_address=POOL, + base_token="SOL", side="SELL", amount=0.01) + c = calls[0] + assert (c["method"], c["path"]) == ("GET", "trading/amm/quote-swap") + assert c["params"] == {"connector": "raydium", "chainNetwork": NET, "poolAddress": POOL, + "baseToken": "SOL", "side": "SELL", "amount": 0.01} + assert "slippagePct" not in c["params"] + + +@pytest.mark.asyncio +async def test_amm_execute_swap_path(client_and_calls): + client, calls = client_and_calls + await client.amm_execute_swap(connector="uniswap", chain_network="ethereum-mainnet", wallet_address=WALLET, + pool_address=POOL, base_token="WETH", side="BUY", amount=1.0, slippage_pct=0.5) + c = calls[0] + assert (c["method"], c["path"]) == ("POST", "trading/amm/execute-swap") + assert c["json"]["walletAddress"] == WALLET + assert c["json"]["chainNetwork"] == "ethereum-mainnet" + assert c["json"]["slippagePct"] == 0.5 + + +@pytest.mark.asyncio +async def test_amm_add_liquidity_omits_position_when_unset(client_and_calls): + client, calls = client_and_calls + await client.amm_add_liquidity(connector="meteora", chain_network=NET, wallet_address=WALLET, + pool_address=POOL, base_token_amount=1.0, quote_token_amount=2.0) + c = calls[0] + assert (c["method"], c["path"]) == ("POST", "trading/amm/add-liquidity") + assert "positionAddress" not in c["json"] # omit => open a new Meteora position + + +@pytest.mark.asyncio +async def test_amm_add_liquidity_includes_position_when_set(client_and_calls): + client, calls = client_and_calls + await client.amm_add_liquidity(connector="meteora", chain_network=NET, wallet_address=WALLET, + pool_address=POOL, base_token_amount=1.0, quote_token_amount=2.0, + position_address="POS123") + assert calls[0]["json"]["positionAddress"] == "POS123" + + +@pytest.mark.asyncio +async def test_amm_remove_liquidity_includes_position_when_set(client_and_calls): + client, calls = client_and_calls + await client.amm_remove_liquidity(connector="meteora", chain_network=NET, wallet_address=WALLET, + pool_address=POOL, percentage_to_remove=100, position_address="POS123") + c = calls[0] + assert (c["method"], c["path"]) == ("POST", "trading/amm/remove-liquidity") + assert c["json"]["percentageToRemove"] == 100 + assert c["json"]["positionAddress"] == "POS123" + + +@pytest.mark.asyncio +async def test_amm_remove_liquidity_omits_position_for_fungible(client_and_calls): + client, calls = client_and_calls + await client.amm_remove_liquidity(connector="raydium", chain_network=NET, wallet_address=WALLET, + pool_address=POOL, percentage_to_remove=50) + assert "positionAddress" not in calls[0]["json"] + + +@pytest.mark.asyncio +async def test_amm_create_pool_meteora_extras(client_and_calls): + client, calls = client_and_calls + await client.amm_create_pool(connector="meteora", chain_network=NET, wallet_address=WALLET, + base_token="SOL", quote_token="USDC", base_token_amount=1.0, + config_address="CFG123") + c = calls[0] + assert (c["method"], c["path"]) == ("POST", "trading/amm/create-pool") + assert c["json"]["configAddress"] == "CFG123" + # Raydium/Uniswap extras and seed-price fields omitted when unset + for k in ("feeConfigIndex", "gasPrice", "maxGas", "quoteTokenAmount", "initialPrice"): + assert k not in c["json"] + + +@pytest.mark.asyncio +async def test_amm_create_pool_raydium_fee_config_index(client_and_calls): + client, calls = client_and_calls + await client.amm_create_pool(connector="raydium", chain_network=NET, wallet_address=WALLET, + base_token="SOL", quote_token="USDC", base_token_amount=1.0, + fee_config_index=0, quote_token_amount=100.0) + c = calls[0] + assert c["json"]["feeConfigIndex"] == 0 + assert c["json"]["quoteTokenAmount"] == 100.0 + assert "configAddress" not in c["json"] + + +@pytest.mark.asyncio +async def test_amm_create_pool_uniswap_gas_extras(client_and_calls): + client, calls = client_and_calls + await client.amm_create_pool(connector="uniswap", chain_network="ethereum-mainnet", wallet_address=WALLET, + base_token="WETH", quote_token="USDC", base_token_amount=1.0, + initial_price=3000.0, gas_price=20.0, max_gas=500000) + c = calls[0] + assert c["json"]["gasPrice"] == 20.0 + assert c["json"]["maxGas"] == 500000 + assert c["json"]["initialPrice"] == 3000.0 + assert "configAddress" not in c["json"] and "feeConfigIndex" not in c["json"]