From 3806af3e23ce926cb13a8691bff092852ea921e7 Mon Sep 17 00:00:00 2001 From: shokollm <270575765+shokollm@users.noreply.github.com> Date: Wed, 8 Apr 2026 14:41:40 +0000 Subject: [PATCH] feat(backend): Implement AVE Cloud integration for Data and Trading APIs - Add tier field to User model for plan detection (free/normal/pro) - Create AVE Cloud API client with all Data API endpoints: - Token search (GET /v2/tokens) - Batch prices (POST /v2/tokens/price) - Token details (GET /v2/tokens/{id}) - Kline data (GET /v2/klines/token/{id}) - Trending tokens (GET /v2/tokens/trending) - Token risk (GET /v2/contracts/{id}) - Add Trading API endpoints: - Chain wallet quote (POST /v1/chain/quote) - Chain wallet swap (POST /v1/chain/swap) - Add tier gating with upsell messaging for Pro features - Handle rate limiting gracefully with 429 responses - Add Pydantic schemas for AVE API requests/responses Fixes #11 --- src/backend/app/api/ave.py | 265 +++++++++++++++++++++++ src/backend/app/db/models.py | 1 + src/backend/app/db/schemas.py | 69 ++++++ src/backend/app/main.py | 3 +- src/backend/app/services/ave/__init__.py | 3 + src/backend/app/services/ave/client.py | 213 ++++++++++++++++++ 6 files changed, 553 insertions(+), 1 deletion(-) create mode 100644 src/backend/app/api/ave.py create mode 100644 src/backend/app/services/ave/__init__.py create mode 100644 src/backend/app/services/ave/client.py diff --git a/src/backend/app/api/ave.py b/src/backend/app/api/ave.py new file mode 100644 index 0000000..8ad5bb2 --- /dev/null +++ b/src/backend/app/api/ave.py @@ -0,0 +1,265 @@ +from fastapi import APIRouter, Depends, HTTPException, status, Request +from sqlalchemy.orm import Session +from typing import Annotated, Optional +import httpx + +from .auth import get_current_user +from ..core.database import get_db +from ..core.config import get_settings +from ..db.models import User +from ..services.ave import AveCloudClient, check_tier_access +from ..db.schemas import ( + AveBatchPricesRequest, + AveKlinesRequest, + AveChainQuoteRequest, + AveChainSwapRequest, +) + +router = APIRouter() + + +def get_ave_client() -> AveCloudClient: + settings = get_settings() + return AveCloudClient(api_key=settings.AVE_API_KEY, plan=settings.AVE_API_PLAN) + + +@router.get("/tokens") +async def search_tokens( + query: Optional[str] = None, + chain: Optional[str] = None, + limit: int = 20, + current_user: User = Depends(get_current_user), +): + client = get_ave_client() + try: + tokens = await client.get_tokens(query=query, chain=chain, limit=limit) + return {"tokens": tokens} + except httpx.HTTPStatusError as e: + if e.response.status_code == 429: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Rate limit exceeded. Please try again later.", + ) + raise HTTPException( + status_code=e.response.status_code, + detail=f"AVE API error: {e.response.text}", + ) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to fetch tokens: {str(e)}", + ) + + +@router.post("/tokens/price") +async def get_batch_prices( + request: AveBatchPricesRequest, + current_user: User = Depends(get_current_user), +): + client = get_ave_client() + try: + prices = await client.get_batch_prices(request.token_ids) + return {"prices": prices} + except httpx.HTTPStatusError as e: + if e.response.status_code == 429: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Rate limit exceeded. Please try again later.", + ) + raise HTTPException( + status_code=e.response.status_code, + detail=f"AVE API error: {e.response.text}", + ) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to fetch batch prices: {str(e)}", + ) + + +@router.get("/tokens/{token_id}") +async def get_token_details( + token_id: str, + current_user: User = Depends(get_current_user), +): + client = get_ave_client() + try: + token = await client.get_token_details(token_id) + if token is None: + return {"token": None, "upsell_message": None} + return {"token": token} + except httpx.HTTPStatusError as e: + if e.response.status_code == 429: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Rate limit exceeded. Please try again later.", + ) + raise HTTPException( + status_code=e.response.status_code, + detail=f"AVE API error: {e.response.text}", + ) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to fetch token details: {str(e)}", + ) + + +@router.get("/klines/{token_id}") +async def get_klines( + token_id: str, + interval: str = "1h", + limit: int = 100, + start_time: Optional[int] = None, + end_time: Optional[int] = None, + current_user: User = Depends(get_current_user), +): + client = get_ave_client() + try: + klines = await client.get_klines( + token_id=token_id, + interval=interval, + limit=limit, + start_time=start_time, + end_time=end_time, + ) + return {"klines": klines} + except httpx.HTTPStatusError as e: + if e.response.status_code == 429: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Rate limit exceeded. Please try again later.", + ) + raise HTTPException( + status_code=e.response.status_code, + detail=f"AVE API error: {e.response.text}", + ) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to fetch klines: {str(e)}", + ) + + +@router.get("/tokens/trending") +async def get_trending_tokens( + chain: Optional[str] = None, + limit: int = 20, + current_user: User = Depends(get_current_user), +): + client = get_ave_client() + try: + tokens = await client.get_trending_tokens(chain=chain, limit=limit) + return {"tokens": tokens} + except httpx.HTTPStatusError as e: + if e.response.status_code == 429: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Rate limit exceeded. Please try again later.", + ) + raise HTTPException( + status_code=e.response.status_code, + detail=f"AVE API error: {e.response.text}", + ) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to fetch trending tokens: {str(e)}", + ) + + +@router.get("/contracts/{contract_id}") +async def get_token_risk( + contract_id: str, + current_user: User = Depends(get_current_user), +): + client = get_ave_client() + try: + risk = await client.get_token_risk(contract_id) + if risk is None: + return {"risk": None, "upsell_message": None} + return {"risk": risk} + except httpx.HTTPStatusError as e: + if e.response.status_code == 429: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Rate limit exceeded. Please try again later.", + ) + raise HTTPException( + status_code=e.response.status_code, + detail=f"AVE API error: {e.response.text}", + ) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to fetch token risk: {str(e)}", + ) + + +@router.post("/chain/quote") +async def get_chain_quote( + request: AveChainQuoteRequest, + current_user: User = Depends(get_current_user), +): + client = get_ave_client() + try: + quote = await client.get_chain_quote( + chain=request.chain, + from_token=request.from_token, + to_token=request.to_token, + amount=request.amount, + slippage=request.slippage, + ) + if quote is None: + return {"quote": None, "upsell_message": None} + return {"quote": quote} + except httpx.HTTPStatusError as e: + if e.response.status_code == 429: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Rate limit exceeded. Please try again later.", + ) + raise HTTPException( + status_code=e.response.status_code, + detail=f"AVE API error: {e.response.text}", + ) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to fetch chain quote: {str(e)}", + ) + + +@router.post("/chain/swap") +async def get_chain_swap( + request: AveChainSwapRequest, + current_user: User = Depends(get_current_user), +): + client = get_ave_client() + try: + swap = await client.get_chain_swap( + chain=request.chain, + from_token=request.from_token, + to_token=request.to_token, + amount=request.amount, + slippage=request.slippage, + wallet_address=request.wallet_address, + ) + if swap is None: + return {"swap": None, "upsell_message": None} + return {"swap": swap} + except httpx.HTTPStatusError as e: + if e.response.status_code == 429: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Rate limit exceeded. Please try again later.", + ) + raise HTTPException( + status_code=e.response.status_code, + detail=f"AVE API error: {e.response.text}", + ) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to fetch chain swap: {str(e)}", + ) diff --git a/src/backend/app/db/models.py b/src/backend/app/db/models.py index 12bed08..0c6089c 100644 --- a/src/backend/app/db/models.py +++ b/src/backend/app/db/models.py @@ -25,6 +25,7 @@ class User(Base): id = Column(String, primary_key=True, default=generate_uuid) email = Column(String, unique=True, nullable=False) password_hash = Column(String, nullable=False) + tier = Column(String, default="free") created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) diff --git a/src/backend/app/db/schemas.py b/src/backend/app/db/schemas.py index 84fd298..7191b61 100644 --- a/src/backend/app/db/schemas.py +++ b/src/backend/app/db/schemas.py @@ -144,3 +144,72 @@ class SignalResponse(BaseModel): class Config: from_attributes = True + + +class AveTokenSearchResponse(BaseModel): + tokens: List[dict] + upsell_message: Optional[str] = None + + +class AveBatchPricesRequest(BaseModel): + token_ids: List[str] + + +class AveBatchPricesResponse(BaseModel): + prices: Dict[str, dict] + upsell_message: Optional[str] = None + + +class AveTokenDetailsResponse(BaseModel): + token: Optional[dict] = None + upsell_message: Optional[str] = None + + +class AveKlinesRequest(BaseModel): + token_id: str + interval: str = "1h" + limit: int = 100 + start_time: Optional[int] = None + end_time: Optional[int] = None + + +class AveKlinesResponse(BaseModel): + klines: List[dict] + upsell_message: Optional[str] = None + + +class AveTrendingTokensResponse(BaseModel): + tokens: List[dict] + upsell_message: Optional[str] = None + + +class AveTokenRiskResponse(BaseModel): + risk: Optional[dict] = None + upsell_message: Optional[str] = None + + +class AveChainQuoteRequest(BaseModel): + chain: str + from_token: str + to_token: str + amount: str + slippage: float = 0.5 + + +class AveChainQuoteResponse(BaseModel): + quote: Optional[dict] = None + upsell_message: Optional[str] = None + + +class AveChainSwapRequest(BaseModel): + chain: str + from_token: str + to_token: str + amount: str + slippage: float = 0.5 + wallet_address: Optional[str] = None + + +class AveChainSwapResponse(BaseModel): + swap: Optional[dict] = None + upsell_message: Optional[str] = None diff --git a/src/backend/app/main.py b/src/backend/app/main.py index f0549b3..c2af974 100644 --- a/src/backend/app/main.py +++ b/src/backend/app/main.py @@ -2,7 +2,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from slowapi import Limiter from slowapi.util import get_remote_address -from .api import auth, bots, backtest, simulate, config +from .api import auth, bots, backtest, simulate, config, ave from .core.limiter import limiter app = FastAPI( @@ -26,6 +26,7 @@ app.include_router(bots.router, prefix="/api/bots", tags=["bots"]) app.include_router(backtest.router, prefix="/api", tags=["backtest"]) app.include_router(simulate.router, prefix="/api", tags=["simulate"]) app.include_router(config.router, prefix="/api/config", tags=["config"]) +app.include_router(ave.router, prefix="/api/ave", tags=["ave"]) @app.get("/") diff --git a/src/backend/app/services/ave/__init__.py b/src/backend/app/services/ave/__init__.py new file mode 100644 index 0000000..5a3d2c6 --- /dev/null +++ b/src/backend/app/services/ave/__init__.py @@ -0,0 +1,3 @@ +from .client import AveCloudClient, check_tier_access + +__all__ = ["AveCloudClient", "check_tier_access"] diff --git a/src/backend/app/services/ave/client.py b/src/backend/app/services/ave/client.py new file mode 100644 index 0000000..1f510cf --- /dev/null +++ b/src/backend/app/services/ave/client.py @@ -0,0 +1,213 @@ +import httpx +from typing import List, Dict, Any, Optional +from datetime import datetime + + +class AveCloudClient: + DATA_API_URL = "https://prod.ave-api.com" + TRADING_API_URL = "https://bot-api.ave.ai" + + def __init__(self, api_key: str, plan: str = "free"): + self.api_key = api_key + self.plan = plan + + def _data_headers(self) -> Dict[str, str]: + return {"X-API-KEY": self.api_key} + + def _trading_headers(self) -> Dict[str, str]: + return {"X-API-KEY": self.api_key, "Content-Type": "application/json"} + + async def get_tokens( + self, + query: Optional[str] = None, + chain: Optional[str] = None, + limit: int = 20, + ) -> List[Dict[str, Any]]: + url = f"{self.DATA_API_URL}/v2/tokens" + params = {"limit": limit} + if query: + params["query"] = query + if chain: + params["chain"] = chain + + async with httpx.AsyncClient() as client: + response = await client.get( + url, headers=self._data_headers(), params=params, timeout=30.0 + ) + response.raise_for_status() + data = response.json() + if data.get("status") == 200: + return data.get("data", []) + raise Exception(f"Failed to fetch tokens: {data}") + + async def get_batch_prices(self, token_ids: List[str]) -> Dict[str, Dict[str, Any]]: + url = f"{self.DATA_API_URL}/v2/tokens/price" + async with httpx.AsyncClient() as client: + response = await client.post( + url, + headers=self._data_headers(), + json={"token_ids": token_ids}, + timeout=30.0, + ) + response.raise_for_status() + data = response.json() + if data.get("status") == 200: + return data.get("data", {}) + return {} + + async def get_token_details(self, token_id: str) -> Optional[Dict[str, Any]]: + url = f"{self.DATA_API_URL}/v2/tokens/{token_id}" + async with httpx.AsyncClient() as client: + response = await client.get(url, headers=self._data_headers(), timeout=30.0) + response.raise_for_status() + data = response.json() + if data.get("status") == 200: + return data.get("data") + return None + + async def get_klines( + self, + token_id: str, + interval: str = "1h", + limit: int = 100, + start_time: Optional[int] = None, + end_time: Optional[int] = None, + ) -> List[Dict[str, Any]]: + url = f"{self.DATA_API_URL}/v2/klines/token/{token_id}" + params = {"interval": interval, "limit": limit} + if start_time: + params["start_time"] = start_time + if end_time: + params["end_time"] = end_time + + async with httpx.AsyncClient() as client: + response = await client.get( + url, headers=self._data_headers(), params=params, timeout=30.0 + ) + response.raise_for_status() + data = response.json() + if data.get("status") == 200: + return data.get("data", []) + raise Exception(f"Failed to fetch klines: {data}") + + async def get_trending_tokens( + self, chain: Optional[str] = None, limit: int = 20 + ) -> List[Dict[str, Any]]: + url = f"{self.DATA_API_URL}/v2/tokens/trending" + params = {"limit": limit} + if chain: + params["chain"] = chain + + async with httpx.AsyncClient() as client: + response = await client.get( + url, headers=self._data_headers(), params=params, timeout=30.0 + ) + response.raise_for_status() + data = response.json() + if data.get("status") == 200: + return data.get("data", []) + raise Exception(f"Failed to fetch trending tokens: {data}") + + async def get_token_risk(self, contract_id: str) -> Optional[Dict[str, Any]]: + url = f"{self.DATA_API_URL}/v2/contracts/{contract_id}" + async with httpx.AsyncClient() as client: + response = await client.get(url, headers=self._data_headers(), timeout=30.0) + response.raise_for_status() + data = response.json() + if data.get("status") == 200: + return data.get("data") + return None + + async def get_chain_quote( + self, + chain: str, + from_token: str, + to_token: str, + amount: str, + slippage: float = 0.5, + ) -> Optional[Dict[str, Any]]: + url = f"{self.TRADING_API_URL}/v1/chain/quote" + payload = { + "chain": chain, + "from_token": from_token, + "to_token": to_token, + "amount": amount, + "slippage": slippage, + } + async with httpx.AsyncClient() as client: + response = await client.post( + url, headers=self._trading_headers(), json=payload, timeout=30.0 + ) + response.raise_for_status() + data = response.json() + if data.get("status") == 200: + return data.get("data") + return None + + async def get_chain_swap( + self, + chain: str, + from_token: str, + to_token: str, + amount: str, + slippage: float = 0.5, + wallet_address: Optional[str] = None, + ) -> Optional[Dict[str, Any]]: + url = f"{self.TRADING_API_URL}/v1/chain/swap" + payload = { + "chain": chain, + "from_token": from_token, + "to_token": to_token, + "amount": amount, + "slippage": slippage, + } + if wallet_address: + payload["wallet_address"] = wallet_address + async with httpx.AsyncClient() as client: + response = await client.post( + url, headers=self._trading_headers(), json=payload, timeout=60.0 + ) + response.raise_for_status() + data = response.json() + if data.get("status") == 200: + return data.get("data") + return None + + +def check_tier_access(user_tier: str, feature: str) -> tuple[bool, Optional[str]]: + tier_access = { + "free": { + "data_rest": True, + "websocket": False, + "chain_wallet": True, + "proxy_wallet": False, + }, + "normal": { + "data_rest": True, + "websocket": False, + "chain_wallet": True, + "proxy_wallet": True, + }, + "pro": { + "data_rest": True, + "websocket": True, + "chain_wallet": True, + "proxy_wallet": True, + }, + } + + if user_tier not in tier_access: + user_tier = "free" + + access = tier_access[user_tier] + if access.get(feature): + return True, None + + upsell_messages = { + "websocket": "Upgrade to Pro plan to access WebSocket streaming data. Visit your account settings.", + "proxy_wallet": "Upgrade to Normal or Pro plan to access Proxy Wallet functionality. Visit your account settings.", + } + + return False, upsell_messages.get( + feature, "Upgrade your plan to access this feature." + ) -- 2.49.1