diff --git a/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml index 944aec2..0cc241a 100644 --- a/.github/workflows/backend-tests.yml +++ b/.github/workflows/backend-tests.yml @@ -53,6 +53,7 @@ jobs: - name: Test env: DATABASE_URL: postgresql+asyncpg://postgres:postgres@localhost:5432/test_db + PERPLEXITY_API_KEY: ${{ secrets.PERPLEXITY_API_KEY }} run: pytest -v --cov=app --cov-report=xml - name: Upload coverage diff --git a/backend/app/agents/scheduler.py b/backend/app/agents/scheduler.py index 8231454..7bef360 100644 --- a/backend/app/agents/scheduler.py +++ b/backend/app/agents/scheduler.py @@ -658,6 +658,8 @@ async def _update_tournament_rankings_async() -> Dict[str, Any]: } + + # ============================================================================ # CRASH RECOVERY TASKS # ============================================================================ diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 7d690f5..55f13c8 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -5,21 +5,21 @@ SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-key") ALGORITHM = "HS256" -def get_current_user(authorization: str = Header(...)) -> dict: +def get_current_user(authorization: str = Header(None)) -> dict: """Verify JWT token""" - if not authorization.startswith("Bearer "): - raise HTTPException(401, "Invalid authorization header") - + if not authorization or not authorization.startswith("Bearer "): + raise HTTPException(401, "Missing or invalid authorization header") + token = authorization.split(" ")[1] - + try: payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) user_address: str = payload.get("sub") role: str = payload.get("role") - + if user_address is None: raise HTTPException(401, "Invalid token") - + return {"address": user_address, "role": role} except JWTError: raise HTTPException(401, "Invalid token") @@ -27,4 +27,4 @@ def get_current_user(authorization: str = Header(...)) -> dict: def require_admin(user: dict = Depends(get_current_user)) -> dict: if user.get("role") != "admin": raise HTTPException(403, "Admin access required") - return user \ No newline at end of file + return user diff --git a/backend/app/api/main.py b/backend/app/api/main.py index 50f63e0..2dce4d3 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -4,10 +4,9 @@ try: from .routers import tournament, agent, agent_state, trade, bet, auth, market_data except ImportError: - from routers import tournament, agent, agent_state, trade, bet, auth, market_data + from app.api.routers import tournament, agent, agent_state, trade, bet, auth, market_data from os import getenv import uvicorn -from contextlib import asynccontextmanager APPLICATION_PORT = 8000 @@ -26,7 +25,7 @@ allow_headers=["*"], # Allow all headers ) -# Register routers +# Register Routers app.include_router(tournament.router, prefix="/tournaments", tags=["Tournaments"]) app.include_router(agent.router, prefix="/agents", tags=["Agents"]) app.include_router(agent_state.router, prefix="/agent-states", tags=["Agent States"]) @@ -38,7 +37,7 @@ @app.get("/") def root(): - return {"message": "Agonus API running 🚀"} + return {"message": "Agonus API running"} if __name__ == "__main__": diff --git a/backend/app/api/routers/agent.py b/backend/app/api/routers/agent.py index 59d1e32..24bc92b 100644 --- a/backend/app/api/routers/agent.py +++ b/backend/app/api/routers/agent.py @@ -36,7 +36,6 @@ async def get_agent_stats(agent_id: UUID, session: AsyncSession = Depends(get_db if not agent: raise HTTPException(status_code=404, detail="Agent Not Found") - # Return the stats JSON field return agent.stats @@ -47,14 +46,12 @@ async def create_agent( admin: dict = Depends(require_admin), ): """POST route for creating a new agent (admin only)""" - # Create Agent model from schema, excluding None values to use model defaults agent_dict = agent_data.model_dump(exclude_none=True) - # Ensure stats and memory have default values if not provided - if 'stats' not in agent_dict: - agent_dict['stats'] = {} - if 'memory' not in agent_dict: - agent_dict['memory'] = {} + if "stats" not in agent_dict: + agent_dict["stats"] = {} + if "memory" not in agent_dict: + agent_dict["memory"] = {} agent = Agent(**agent_dict) @@ -76,7 +73,6 @@ async def update_agent( if not db_agent: raise HTTPException(status_code=404, detail="Agent Not Found") - # Update only provided fields update_data = agent_data.model_dump(exclude_unset=True) for key, value in update_data.items(): @@ -101,7 +97,6 @@ async def update_agent_stats( if not agent: raise HTTPException(status_code=404, detail="Agent Not Found") - # Merge new stats with existing stats agent.stats = {**agent.stats, **stats} session.add(agent) @@ -123,7 +118,6 @@ async def update_agent_memory( if not agent: raise HTTPException(status_code=404, detail="Agent Not Found") - # Merge new memory with existing memory agent.memory = {**agent.memory, **memory} session.add(agent) @@ -139,15 +133,12 @@ async def delete_agent( session: AsyncSession = Depends(get_db), admin: dict = Depends(require_admin), ): - """DELETE route for deleting an agent (admin only - use with caution)""" + """DELETE route for deleting an agent (admin only)""" agent = await session.get(Agent, agent_id) if not agent: raise HTTPException(status_code=404, detail="Agent Not Found") - # Note: This will fail if agent has related trades/bets due to foreign keys - # Consider soft delete instead (add 'active' boolean field) await session.delete(agent) await session.commit() return {"message": f"Agent {agent_id} deleted successfully"} - diff --git a/backend/app/api/routers/auth.py b/backend/app/api/routers/auth.py index f7499e9..f97d9af 100644 --- a/backend/app/api/routers/auth.py +++ b/backend/app/api/routers/auth.py @@ -11,37 +11,56 @@ SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-key") ALGORITHM = "HS256" +# Load admin addresses from env raw_addresses = os.getenv("ADMIN_ADDRESSES", "") -ADMIN_ADDRESSES = set(addr.strip().lower() for addr in raw_addresses.split(",") if addr.strip()) +ADMIN_ADDRESSES = { + addr.strip().lower() for addr in raw_addresses.split(",") if addr.strip() +} + class WalletSignIn(BaseModel): address: str signature: str -@router.post("/wallet") # Changed from /wallet-signin + +@router.post("/wallet") def wallet_auth(data: WalletSignIn) -> dict: - """Sign in with wallet and return JWT""" - + """ + Verify wallet signature and return JWT. + Used for login on the frontend. + """ + # Verify signature message = "Sign in to Agonus" + try: w3 = Web3() encoded_message = encode_defunct(text=message) - recovered_address = w3.eth.account.recover_message(encoded_message, signature=data.signature) - + + recovered_address = w3.eth.account.recover_message( + encoded_message, signature=data.signature + ) + if recovered_address.lower() != data.address.lower(): - raise HTTPException(401, "Invalid signature") + raise HTTPException(status_code=401, detail="Invalid signature") + except Exception as e: - raise HTTPException(401, f"Signature verification failed: {str(e)}") - - # Check role and issue JWT + raise HTTPException(status_code=401, detail=f"Signature verification failed: {str(e)}") + + # Determine role role = "admin" if data.address.lower() in ADMIN_ADDRESSES else "user" - + print(f"[AUTH] Wallet: {data.address.lower()}, ADMIN_ADDRESSES: {ADMIN_ADDRESSES}, Role: {role}") + + # Generate JWT expire = datetime.utcnow() + timedelta(hours=24) token = jwt.encode( - {"sub": data.address.lower(), "role": role, "exp": expire}, + { + "sub": data.address.lower(), + "role": role, + "exp": expire, + }, SECRET_KEY, - algorithm=ALGORITHM + algorithm=ALGORITHM, ) - + return {"access_token": token, "token_type": "bearer"} diff --git a/backend/app/api/routers/bet.py b/backend/app/api/routers/bet.py index 4302abe..5ce4b5c 100644 --- a/backend/app/api/routers/bet.py +++ b/backend/app/api/routers/bet.py @@ -1,4 +1,5 @@ from fastapi import APIRouter, Depends, HTTPException +from datetime import timezone from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from uuid import UUID @@ -20,10 +21,11 @@ async def list_bets(session: AsyncSession = Depends(get_db)): return bets -# IMPORTANT: This route must come BEFORE /{bet_id} to avoid path conflicts +# IMPORTANT: This route must come BEFORE /{bet_id} @router.get("/my-bets", response_model=list[BetResponse]) async def get_user_bets( - session: AsyncSession = Depends(get_db), user: dict = Depends(get_current_user) + session: AsyncSession = Depends(get_db), + user: dict = Depends(get_current_user) ): """GET route for current user's bets""" statement = select(Bet).where(Bet.user_address == user["address"]) @@ -48,11 +50,14 @@ async def create_bet( user: dict = Depends(get_current_user), ): """POST route for creating a new bet""" - # Override user_address with authenticated user's address - bet_dict = bet_data.model_dump() - bet_dict["user_address"] = user[ - "address" - ] # Ensure user can only bet for themselves + bet_dict = bet_data.model_dump(exclude_unset=True) + bet_dict["user_address"] = user["address"] # Force bet ownership + + # Remove fields not in the database model + bet_dict.pop("tx_hash", None) + # Store naive UTC if DB column is TIMESTAMP WITHOUT TIME ZONE + if "placed_at" in bet_dict and bet_dict["placed_at"] is not None: + bet_dict["placed_at"] = bet_dict["placed_at"].replace(tzinfo=None) bet = Bet(**bet_dict) @@ -64,9 +69,11 @@ async def create_bet( @router.patch("/{bet_id}/settle", response_model=BetResponse) async def settle_bet( - bet_id: UUID, payout: float, session: AsyncSession = Depends(get_db) + bet_id: UUID, + payout: float, + session: AsyncSession = Depends(get_db), ): - """PATCH route for settling a bet (admin/system use)""" + """PATCH route for settling a bet""" bet = await session.get(Bet, bet_id) if not bet: raise HTTPException(status_code=404, detail="Bet Not Found") @@ -80,20 +87,20 @@ async def settle_bet( session.add(bet) await session.commit() await session.refresh(bet) - return bet @router.put("/{bet_id}", response_model=BetResponse) async def update_bet( - bet_id: UUID, bet_data: BetUpdate, session: AsyncSession = Depends(get_db) + bet_id: UUID, + bet_data: BetUpdate, + session: AsyncSession = Depends(get_db), ): - """PUT route for updating a bet (typically only for settling)""" + """PUT route for updating a bet""" db_bet = await session.get(Bet, bet_id) if not db_bet: raise HTTPException(status_code=404, detail="Bet Not Found") - # Update only provided fields update_data = bet_data.model_dump(exclude_unset=True) for key, value in update_data.items(): @@ -102,13 +109,12 @@ async def update_bet( session.add(db_bet) await session.commit() await session.refresh(db_bet) - return db_bet @router.delete("/{bet_id}") async def delete_bet(bet_id: UUID, session: AsyncSession = Depends(get_db)): - """DELETE route for deleting a bet (use with caution - breaks audit trail)""" + """DELETE route for deleting a bet""" bet = await session.get(Bet, bet_id) if not bet: raise HTTPException(status_code=404, detail="Bet Not Found") diff --git a/backend/app/api/routers/market_data.py b/backend/app/api/routers/market_data.py index b13ea0e..16fc80f 100644 --- a/backend/app/api/routers/market_data.py +++ b/backend/app/api/routers/market_data.py @@ -2,7 +2,8 @@ import requests from typing import Optional -router = APIRouter() +# Add correct prefix + tag so API documentation is clean +router = APIRouter(prefix="/market-data", tags=["Market Data"]) @router.get("/prices") @@ -15,13 +16,11 @@ async def get_market_prices( ): """GET route for live cryptocurrency prices from CoinGecko API""" try: - # Clean up token list token_list = [t.strip() for t in tokens.split(",") if t.strip()] if not token_list: raise HTTPException(status_code=400, detail="No tokens specified") - # Call CoinGecko API url = "https://api.coingecko.com/api/v3/simple/price" params = { "ids": ",".join(token_list), @@ -36,7 +35,6 @@ async def get_market_prices( data = response.json() - # Transform response to be more frontend-friendly result = {} for token_id in token_list: if token_id in data: @@ -73,7 +71,6 @@ async def get_single_price( ): """GET route for a single token's current price""" try: - # Call CoinGecko API url = "https://api.coingecko.com/api/v3/simple/price" params = { "ids": token.lower(), diff --git a/backend/app/api/routers/runtime_agents.py b/backend/app/api/routers/runtime_agents.py new file mode 100644 index 0000000..1d8a73e --- /dev/null +++ b/backend/app/api/routers/runtime_agents.py @@ -0,0 +1,169 @@ +from fastapi import APIRouter, HTTPException +from datetime import datetime +from typing import Dict, Any + +from app.tools.market_data import MarketDataTool +from app.agents.executor import ACTIVE_AGENTS + +router = APIRouter(prefix="/runtime/agents", tags=["runtime-agents"]) + +# Shared market tool instance for caching +_market_tool = MarketDataTool() + + +@router.get("/{agent_id}/portfolio") +async def get_agent_portfolio(agent_id: int) -> Dict[str, Any]: + """Get the agent's current portfolio with live pricing.""" + if agent_id not in ACTIVE_AGENTS: + raise HTTPException(status_code=404, detail=f"Agent {agent_id} not found") + + agent = ACTIVE_AGENTS[agent_id] + + holdings = agent.get_holdings() + cash = agent.get_available_cash() + + all_prices = _market_tool.get_all_prices() + + holdings_value = 0.0 + holdings_enriched = {} + + for token, qty in holdings.items(): + price = all_prices.get(token, 0.0) + value = qty * price + holdings_value += value + + holdings_enriched[token] = { + "quantity": qty, + "current_price": price, + "total_value": value, + } + + total_value = cash + holdings_value + + portfolio = agent.get_portfolio_status() + + return { + "agent_id": agent_id, + "cash": cash, + "holdings": holdings_enriched, + "holdings_value": holdings_value, + "total_value": total_value, + "performance": { + "starting_value": portfolio.starting_val, + "realized_pnl": portfolio.realized_pnl, + "unrealized_pnl": portfolio.unrealized_pnl, + "total_pnl": portfolio.realized_pnl + portfolio.unrealized_pnl, + "roi": portfolio.roi, + "roi_percent": portfolio.roi * 100, + "num_trades": portfolio.num_trades, + "win_rate": portfolio.win_rate, + "num_winning_trades": portfolio.num_winning_trades, + "num_losing_trades": portfolio.num_losing_trades, + }, + "timestamp": datetime.utcnow().isoformat(), + } + + +@router.get("/") +async def list_agents() -> Dict[str, Any]: + """List all active agents with summaries.""" + + agents_list = [] + + all_prices = _market_tool.get_all_prices() if ACTIVE_AGENTS else {} + + for agent_id, agent in ACTIVE_AGENTS.items(): + holdings = agent.get_holdings() + cash = agent.get_available_cash() + + holdings_value = sum( + qty * all_prices.get(token, 0.0) for token, qty in holdings.items() + ) + + total_value = cash + holdings_value + + portfolio = agent.get_portfolio_status() + + agents_list.append({ + "agent_id": agent_id, + "name": f"Agent-{agent_id}", + "type": agent.__class__.__name__, + "personality": agent.personality, + "risk_score": agent.risk_score, + "total_value": total_value, + "cash": cash, + "holdings_value": holdings_value, + "roi": portfolio.roi, + "roi_percent": portfolio.roi * 100, + "num_trades": portfolio.num_trades, + "win_rate": portfolio.win_rate, + }) + + return { + "agents": agents_list, + "total_agents": len(agents_list), + "timestamp": datetime.utcnow().isoformat(), + } + + +@router.get("/market/prices") +async def get_market_prices() -> Dict[str, Any]: + """Get current market prices for all supported tokens.""" + prices = _market_tool.get_all_prices() + sentiment = _market_tool.get_market_sentiment() + + enriched = {} + + for token, price in prices.items(): + try: + volume = _market_tool.get_volume(token) + enriched[token] = { + "symbol": token, + "price": price, + "volume_24h": volume, + } + except: + enriched[token] = { + "symbol": token, + "price": price, + } + + return { + "prices": enriched, + "market_sentiment": sentiment, + "timestamp": datetime.utcnow().isoformat(), + } + + +@router.get("/{agent_id}/trades") +async def get_agent_trades(agent_id: int, limit: int = 20) -> Dict[str, Any]: + """Get recent trades for an agent.""" + if agent_id not in ACTIVE_AGENTS: + raise HTTPException(status_code=404, detail=f"Agent {agent_id} not found") + + agent = ACTIVE_AGENTS[agent_id] + recent_trades = agent.get_short_term_memory(limit) + + trades_list = [ + { + "trade_id": t.trade_id, + "action": t.action, + "token": t.token, + "quantity": t.qty, + "price": t.price, + "total_value": t.qty * t.price, + "confidence": t.confidence, + "realized_pnl": t.realized_pnl, + "roi": t.roi, + "summary": t.summary, + "timestamp": t.timestamp.isoformat(), + } + for t in recent_trades + ] + + return { + "agent_id": agent_id, + "trades": trades_list, + "count": len(trades_list), + "timestamp": datetime.utcnow().isoformat(), + } diff --git a/backend/app/api/routers/tournament.py b/backend/app/api/routers/tournament.py index 008bf38..89ece44 100644 --- a/backend/app/api/routers/tournament.py +++ b/backend/app/api/routers/tournament.py @@ -1,36 +1,45 @@ +import logging from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from uuid import UUID +from datetime import datetime, timezone from ...agents.data_classes import Portfolio -import logging -import asyncio -from typing import List, Dict, Any, Optional -from uuid import UUID -from datetime import datetime, timezone, timedelta - -from celery import Task, group, chain -from sqlalchemy import select, update -from sqlalchemy.ext.asyncio import AsyncSession - -from ...celery_config import celery_app from ...db.database import AsyncSessionLocal, get_db from ...db.models import Tournament, Agent, AgentState, StatusEnum -from ...agents.executor import TradingAgent from ...agents.tools import DatabaseTool from ...schemas.tournament import ( TournamentCreate, TournamentUpdate, TournamentResponse, + TournamentContractLink, + TournamentOnchainCreate, + TournamentOnchainSettle, ) from ...schemas.agent_state import AgentStateResponse from ..deps import require_admin from ...agents.scheduler import ( run_agent_decision, - initialize_tournament_agents, + _initialize_tournament_agents_async, ) +from ...onchain.agonus_betting import get_agonus_client + +logger = logging.getLogger(__name__) + + +def _extract_revert_reason(exc: Exception) -> str | None: + """Extract a human-readable revert reason from a web3 exception.""" + msg = str(exc) + if "execution reverted" in msg.lower(): + return msg + if hasattr(exc, "args") and exc.args: + for arg in exc.args: + if isinstance(arg, dict) and "message" in arg: + return arg["message"] + return None + router = APIRouter() @@ -57,146 +66,32 @@ async def get_tournament(tournament_id: UUID, session: AsyncSession = Depends(ge async def get_tournament_leaderboard( tournament_id: UUID, session: AsyncSession = Depends(get_db) ): - """GET route for tournament leaderboard - agents ranked by portfolio value""" - # Verify tournament exists + """GET leaderboard ordered by portfolio value""" tournament = await session.get(Tournament, tournament_id) if not tournament: raise HTTPException(status_code=404, detail="Tournament Not Found") - # Get all agent states for this tournament, ordered by portfolio value statement = ( select(AgentState) .where(AgentState.tournament_id == tournament_id) .order_by(AgentState.portfolio_value_usd.desc()) ) result = await session.execute(statement) - agent_states = result.scalars().all() - - return agent_states + return result.scalars().all() @router.get("/{tournament_id}/agents", response_model=list[AgentStateResponse]) async def get_tournament_agents( tournament_id: UUID, session: AsyncSession = Depends(get_db) ): - """GET route for all agents in a tournament with their current state""" - # Verify tournament exists + """GET agents participating in tournament""" tournament = await session.get(Tournament, tournament_id) if not tournament: raise HTTPException(status_code=404, detail="Tournament Not Found") - # Get all agent states for this tournament statement = select(AgentState).where(AgentState.tournament_id == tournament_id) result = await session.execute(statement) - agent_states = result.scalars().all() - - return agent_states - - -# @router.post("/", response_model=TournamentResponse, status_code=201) -# async def create_tournament( -# tournament_data: TournamentCreate, -# session: AsyncSession = Depends(get_db), -# admin: dict = Depends(require_admin), -# ): -# pass - -# def initialize_tournament_agents( -# tournament_uuid: str, -# agent_uuids: List[str] -# ) -> Dict[str, Any]: -# """ -# Initialize agent states for a new tournament. - -# Args: -# tournament_uuid: Tournament UUID (as string) -# agent_uuids: List of agent UUIDs (as strings) - -# Returns: -# Dict with initialization results -# """ -# # logger.info(f"Initializing agents for tournament: {tournament_uuid}") -# try: -# return asyncio.run( -# _initialize_tournament_agents_async( -# UUID(tournament_uuid), -# [UUID(uuid) for uuid in agent_uuids] -# ) -# ) -# except Exception as e: -# # logger.error(f"Failed to initialize tournament agents: {e}") -# raise - - -# async def _initialize_tournament_agents_async( -# tournament_uuid: UUID, -# agent_uuids: List[UUID] -# ) -> Dict[str, Any]: -# """ -# Async implementation of agent initialization. -# """ -# async with AsyncSessionLocal() as session: -# db_tool = DatabaseTool(session) -# initialized = [] - -# for agent_uuid in agent_uuids: -# # Load agent -# stmt = select(Agent).where(Agent.id == agent_uuid) -# result = await session.execute(stmt) -# agent = result.scalar_one_or_none() - -# if not agent: -# # logger.warning(f"Agent not found: {agent_uuid}") -# continue - -# # Create initial portfolio -# from ...agents.data_classes import Portfolio - -# portfolio = Portfolio( -# agent_id=agent.name, -# cash=500.0, -# holdings={}, -# starting_val=500.0, -# total_value=500.0 -# ) - -# # Save initial state -# await db_tool.save_agent_state( -# agent_uuid=agent_uuid, -# tournament_uuid=tournament_uuid, -# portfolio=portfolio, -# rank=0, -# last_decision="Tournament initialized" -# ) - -# initialized.append(str(agent_uuid)) -# # logger.info(f"Initialized agent: {agent.name}") - -# return { -# "tournament_id": str(tournament_uuid), -# "agents_initialized": len(initialized), -# "agent_ids": initialized, -# "timestamp": datetime.now(timezone.utc).isoformat() -# } - -# """POST route to create a new tournament""" -# # Create tournament from schema, excluding agent_ids (not a Tournament model field) -# tournament_dict = tournament_data.model_dump(exclude={"agent_ids"}) -# tournament = Tournament(**tournament_dict) - -# session.add(tournament) -# await session.commit() -# await session.refresh(tournament) - -# return tournament - - -# @router.post("/{tournament_id}/start") -# async def start_tournament(tournament_id: str): -# initialize_tournament_agents.delay( -# tournament_uuid=tournament_id, agent_uuids=["uuid-1", "uuid-2"] -# ) -# return {"message": "Tournament initialization started"} + return result.scalars().all() async def initialize_agents_for_tournament( @@ -239,7 +134,7 @@ async def create_tournament( data: TournamentCreate, session: AsyncSession = Depends(get_db), ): - # 1️⃣ Create the tournament row + # Create the tournament row tournament = Tournament( name=data.name, start_date=data.start_date, @@ -251,27 +146,236 @@ async def create_tournament( session.add(tournament) await session.flush() # ensures tournament.id exists without commit - # 2️⃣ Initialize agent states + # Initialize agent states await initialize_agents_for_tournament( session=session, tournament_id=tournament.id, agent_ids=data.agent_ids, ) - # 3️⃣ Commit the whole transaction atomically + # Commit the whole transaction atomically await session.commit() await session.refresh(tournament) return tournament +# ── On-chain smart contract endpoints (admin-gated) ── + + +@router.post( + "/{tournament_id}/onchain/create", + response_model=TournamentContractLink, + status_code=201, +) +async def create_onchain_tournament( + tournament_id: UUID, + payload: TournamentOnchainCreate, + session: AsyncSession = Depends(get_db), + admin: dict = Depends(require_admin), +): + """Create tournament on-chain and link it to DB record.""" + tournament = await session.get(Tournament, tournament_id) + if not tournament: + raise HTTPException(status_code=404, detail="Tournament Not Found") + + if tournament.contract_tournament_id is not None: + raise HTTPException(status_code=400, detail="Tournament already linked on-chain") + + if len(payload.agent_ids) < 2: + raise HTTPException(status_code=400, detail="At least 2 agents required") + + try: + client = get_agonus_client() + result = client.create_tournament(agent_count=len(payload.agent_ids)) + except (ValueError, ConnectionError) as e: + raise HTTPException(status_code=400, detail=f"Configuration error: {e}") + except Exception as e: + logger.exception("On-chain create failed") + detail = _extract_revert_reason(e) or str(e) + raise HTTPException(status_code=400, detail=f"On-chain create failed: {detail}") + + # Sort agent IDs deterministically so the mapping is always the same + sorted_ids = sorted(str(aid) for aid in payload.agent_ids) + mapping = {agent_id: idx + 1 for idx, agent_id in enumerate(sorted_ids)} + tournament.contract_tournament_id = result.contract_tournament_id + tournament.agent_contract_mapping = mapping + + session.add(tournament) + await session.commit() + await session.refresh(tournament) + + return TournamentContractLink( + contract_tournament_id=result.contract_tournament_id or 0, + tx_hash=result.tx_hash, + ) + + +@router.post("/{tournament_id}/start") +async def start_tournament( + tournament_id: UUID, + session: AsyncSession = Depends(get_db), + admin: dict = Depends(require_admin), +): + """Start tournament -- initializes agent states via background task.""" + tournament = await session.get(Tournament, tournament_id) + if not tournament: + raise HTTPException(status_code=404, detail="Tournament Not Found") + if not tournament.agent_contract_mapping: + raise HTTPException(status_code=400, detail="No agents mapped. Link on-chain first.") + + agent_uuids = list(tournament.agent_contract_mapping.keys()) + await _initialize_tournament_agents_async( + tournament_id, [UUID(u) for u in agent_uuids] + ) + + # Update agent stats: increment total_tournaments + for uid in agent_uuids: + agent = await session.get(Agent, UUID(uid)) + if agent: + stats = dict(agent.stats or {}) + stats["total_tournaments"] = stats.get("total_tournaments", 0) + 1 + agent.stats = stats + session.add(agent) + + tournament.status = "live" + session.add(tournament) + await session.commit() + + return {"message": "Tournament started", "agents_count": len(agent_uuids)} + + +@router.post("/{tournament_id}/onchain/close") +async def close_onchain_betting( + tournament_id: UUID, + session: AsyncSession = Depends(get_db), + admin: dict = Depends(require_admin), +): + """Close betting on-chain.""" + tournament = await session.get(Tournament, tournament_id) + if not tournament: + raise HTTPException(status_code=404, detail="Tournament Not Found") + if tournament.contract_tournament_id is None: + raise HTTPException(status_code=400, detail="Tournament not linked on-chain") + if tournament.status == "completed": + raise HTTPException(status_code=400, detail="Tournament already completed or cancelled") + if tournament.betting_closed: + raise HTTPException(status_code=400, detail="Betting already closed") + if tournament.winner_agent_id is not None: + raise HTTPException(status_code=400, detail="Tournament already settled") + + try: + client = get_agonus_client() + result = client.close_betting(tournament.contract_tournament_id) + except (ValueError, ConnectionError) as e: + raise HTTPException(status_code=400, detail=f"Configuration error: {e}") + except Exception as e: + logger.exception("On-chain close failed") + detail = _extract_revert_reason(e) or str(e) + raise HTTPException(status_code=400, detail=f"On-chain close failed: {detail}") + + tournament.betting_closed = True + session.add(tournament) + await session.commit() + + return {"tx_hash": result.tx_hash} + + +@router.post("/{tournament_id}/onchain/settle") +async def settle_onchain_tournament( + tournament_id: UUID, + payload: TournamentOnchainSettle, + session: AsyncSession = Depends(get_db), + admin: dict = Depends(require_admin), +): + """Settle tournament on-chain using winner agent mapping.""" + tournament = await session.get(Tournament, tournament_id) + if not tournament: + raise HTTPException(status_code=404, detail="Tournament Not Found") + if tournament.contract_tournament_id is None: + raise HTTPException(status_code=400, detail="Tournament not linked on-chain") + if not tournament.betting_closed: + raise HTTPException(status_code=400, detail="Must close betting before settling") + if tournament.winner_agent_id is not None: + raise HTTPException(status_code=400, detail="Tournament already settled") + + contract_agent_id = tournament.agent_contract_mapping.get(str(payload.winner_agent_id)) + if not contract_agent_id: + raise HTTPException(status_code=400, detail="Winner agent not mapped on-chain") + + try: + client = get_agonus_client() + result = client.settle_tournament( + tournament.contract_tournament_id, contract_agent_id + ) + except (ValueError, ConnectionError) as e: + raise HTTPException(status_code=400, detail=f"Configuration error: {e}") + except Exception as e: + logger.exception("On-chain settle failed") + detail = _extract_revert_reason(e) or str(e) + raise HTTPException(status_code=400, detail=f"On-chain settle failed: {detail}") + + tournament.winner_agent_id = payload.winner_agent_id + tournament.status = "completed" + session.add(tournament) + + # Update agent stats: increment wins for winner, recalculate win_rate for all + for uid in (tournament.agent_contract_mapping or {}).keys(): + agent = await session.get(Agent, UUID(uid)) + if not agent: + continue + stats = dict(agent.stats or {}) + if UUID(uid) == payload.winner_agent_id: + stats["wins"] = stats.get("wins", 0) + 1 + total = stats.get("total_tournaments", 0) + wins = stats.get("wins", 0) + stats["win_rate"] = wins / total if total > 0 else 0 + agent.stats = stats + session.add(agent) + + await session.commit() + + return {"tx_hash": result.tx_hash} + + +@router.post("/{tournament_id}/onchain/cancel") +async def cancel_onchain_tournament( + tournament_id: UUID, + session: AsyncSession = Depends(get_db), + admin: dict = Depends(require_admin), +): + """Cancel tournament on-chain.""" + tournament = await session.get(Tournament, tournament_id) + if not tournament: + raise HTTPException(status_code=404, detail="Tournament Not Found") + if tournament.contract_tournament_id is None: + raise HTTPException(status_code=400, detail="Tournament not linked on-chain") + if tournament.winner_agent_id is not None: + raise HTTPException(status_code=400, detail="Tournament already settled, cannot cancel") + + try: + client = get_agonus_client() + result = client.cancel_tournament(tournament.contract_tournament_id) + except (ValueError, ConnectionError) as e: + raise HTTPException(status_code=400, detail=f"Configuration error: {e}") + except Exception as e: + logger.exception("On-chain cancel failed") + detail = _extract_revert_reason(e) or str(e) + raise HTTPException(status_code=400, detail=f"On-chain cancel failed: {detail}") + + tournament.status = "completed" + tournament.betting_closed = True + session.add(tournament) + await session.commit() + + return {"tx_hash": result.tx_hash} + + @router.post("/agents/{agent_id}/force-run") async def force_agent_run(agent_id: str, tournament_id: str): - # 2. Force a single agent to think NOW task = run_agent_decision.delay( agent_uuid=agent_id, tournament_uuid=tournament_id, recover_from_crash=True ) - return {"task_id": task.id, "status": "Queued"} @@ -282,12 +386,11 @@ async def update_tournament( session: AsyncSession = Depends(get_db), admin: dict = Depends(require_admin), ): - """PUT route for updating a tournament""" + """PUT update tournament""" db_tournament = await session.get(Tournament, tournament_id) if not db_tournament: raise HTTPException(status_code=404, detail="Tournament Not Found") - # Update only provided fields update_data = tournament_data.model_dump(exclude_unset=True) for key, value in update_data.items(): @@ -306,7 +409,7 @@ async def delete_tournament( session: AsyncSession = Depends(get_db), admin: dict = Depends(require_admin), ): - """DELETE route for deleting a tournament""" + """DELETE tournament""" tournament = await session.get(Tournament, tournament_id) if not tournament: raise HTTPException(status_code=404, detail="Tournament Not Found") diff --git a/backend/app/api/routers/trade.py b/backend/app/api/routers/trade.py index 5269931..9ae4aa6 100644 --- a/backend/app/api/routers/trade.py +++ b/backend/app/api/routers/trade.py @@ -68,11 +68,9 @@ async def create_trade( trade_data: TradeCreate, session: AsyncSession = Depends(get_db) ): """POST route for creating a new trade""" - # Create Trade model from schema trade = Trade(**trade_data.model_dump()) session.add(trade) await session.commit() await session.refresh(trade) return trade - diff --git a/backend/app/db/database.py b/backend/app/db/database.py index d4901e9..dd33eb2 100644 --- a/backend/app/db/database.py +++ b/backend/app/db/database.py @@ -6,9 +6,7 @@ # Load environment variables load_dotenv() -DATABASE_URL = os.getenv("DATABASE_URL") -if not DATABASE_URL: - raise ValueError("DATABASE_URL environment variable is not set") +DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./dev.db") # --- SCALABILITY TUNING --- # These defaults are safe, but you should override them in docker-compose: diff --git a/backend/app/db/models.py b/backend/app/db/models.py index bae1a38..2f15fb0 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -9,6 +9,7 @@ String, Numeric, Integer, + Boolean, Enum as SQLEnum, Index, DateTime, @@ -73,6 +74,7 @@ class Tournament(Base): agent_contract_mapping: Mapped[dict] = mapped_column( JSON, default=dict ) # {"uuid": 1, "uuid2": 2} + betting_closed: Mapped[bool] = mapped_column(Boolean, default=False) id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4) name: Mapped[str] = mapped_column(String, index=True) status: Mapped[StatusEnum] = mapped_column( @@ -161,20 +163,21 @@ class Bet(Base): user_address: Mapped[str] = mapped_column(String, index=True) agent_id: Mapped[UUID] = mapped_column(ForeignKey("agent.id"), index=True) tournament_id: Mapped[UUID] = mapped_column(ForeignKey("tournament.id"), index=True) - amount: Mapped[Decimal] = mapped_column(Numeric(precision=20, scale=2)) - odds: Mapped[Decimal] = mapped_column(Numeric(precision=10, scale=2)) + amount: Mapped[Decimal] = mapped_column(Numeric(precision=20, scale=8)) + odds: Mapped[Decimal] = mapped_column(Numeric(precision=10, scale=4)) placed_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) ) settled: Mapped[bool] = mapped_column(default=False) payout: Mapped[Optional[Decimal]] = mapped_column( - Numeric(precision=20, scale=2), default=None + Numeric(precision=20, scale=8), default=None ) tournament: Mapped["Tournament"] = relationship(back_populates="bets") agent: Mapped["Agent"] = relationship(back_populates="bets") + class PlanItem(Base): __tablename__ = "plan_item" diff --git a/backend/app/onchain/abi/AgonusBetting.json b/backend/app/onchain/abi/AgonusBetting.json new file mode 100644 index 0000000..13815af --- /dev/null +++ b/backend/app/onchain/abi/AgonusBetting.json @@ -0,0 +1,575 @@ +[ + { + "inputs": [], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "OwnableInvalidOwner", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "OwnableUnauthorizedAccount", + "type": "error" + }, + { + "inputs": [], + "name": "ReentrancyGuardReentrantCall", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "user", + "type": "address" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "tournamentId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "agentId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "BetPlaced", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "tournamentId", + "type": "uint256" + } + ], + "name": "BettingClosed", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "previousOwner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "OwnershipTransferred", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "tournamentId", + "type": "uint256" + } + ], + "name": "TournamentCancelled", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "tournamentId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "agentCount", + "type": "uint256" + } + ], + "name": "TournamentCreated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "tournamentId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "winningAgentId", + "type": "uint256" + } + ], + "name": "TournamentSettled", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "user", + "type": "address" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "tournamentId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "WinningsClaimed", + "type": "event" + }, + { + "inputs": [], + "name": "BP_DIVISOR", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MIN_BET", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "PLATFORM_FEE_BP", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "agentPools", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "tournamentId", + "type": "uint256" + }, + { + "internalType": "address", + "name": "user", + "type": "address" + } + ], + "name": "calculatePayout", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "tournamentId", + "type": "uint256" + } + ], + "name": "cancelTournament", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "tournamentId", + "type": "uint256" + } + ], + "name": "claimWinnings", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "tournamentId", + "type": "uint256" + } + ], + "name": "closeBetting", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "agentCount", + "type": "uint256" + } + ], + "name": "createTournament", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "tournamentId", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "agentId", + "type": "uint256" + } + ], + "name": "getAgentOdds", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "tournamentId", + "type": "uint256" + }, + { + "internalType": "address", + "name": "user", + "type": "address" + }, + { + "internalType": "uint256", + "name": "agentId", + "type": "uint256" + } + ], + "name": "getUserBetOnAgent", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "hasClaimed", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "nextTournamentId", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "owner", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "tournamentId", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "agentId", + "type": "uint256" + } + ], + "name": "placeBet", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [], + "name": "platformWallet", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "renounceOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newWallet", + "type": "address" + } + ], + "name": "setPlatformWallet", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "tournamentId", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "winningAgentId", + "type": "uint256" + } + ], + "name": "settleTournament", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "tournaments", + "outputs": [ + { + "internalType": "bool", + "name": "isActive", + "type": "bool" + }, + { + "internalType": "bool", + "name": "isSettled", + "type": "bool" + }, + { + "internalType": "uint256", + "name": "totalPool", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "winningAgentId", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "agentCount", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "address", + "name": "", + "type": "address" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "userBets", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "stateMutability": "payable", + "type": "receive" + } +] \ No newline at end of file diff --git a/backend/app/onchain/agonus_betting.py b/backend/app/onchain/agonus_betting.py new file mode 100644 index 0000000..824af84 --- /dev/null +++ b/backend/app/onchain/agonus_betting.py @@ -0,0 +1,110 @@ +import json +import os +from dataclasses import dataclass +from typing import Any, Optional + +from web3 import Web3 +from web3.contract import Contract +from eth_account import Account + + +@dataclass +class TxResult: + tx_hash: str + contract_tournament_id: Optional[int] = None + + +class AgonusBettingClient: + def __init__( + self, + rpc_url: str, + private_key: str, + contract_address: str, + abi_path: str, + chain_id: Optional[int] = None, + ) -> None: + if not rpc_url: + raise ValueError("RPC URL is required") + if not private_key: + raise ValueError("Private key is required") + if not contract_address: + raise ValueError("Contract address is required") + + self.w3 = Web3(Web3.HTTPProvider(rpc_url)) + if not self.w3.is_connected(): + raise ConnectionError("Failed to connect to RPC") + + self.account = Account.from_key(private_key) + self.chain_id = chain_id or int(os.getenv("CHAIN_ID", "0")) or self.w3.eth.chain_id + self.contract = self._load_contract(contract_address, abi_path) + + def _load_contract(self, contract_address: str, abi_path: str) -> Contract: + with open(abi_path, "r", encoding="utf-8") as f: + abi = json.load(f) + return self.w3.eth.contract( + address=Web3.to_checksum_address(contract_address), + abi=abi, + ) + + def _build_tx(self, fn) -> dict[str, Any]: + nonce = self.w3.eth.get_transaction_count(self.account.address) + tx = fn.build_transaction( + { + "from": self.account.address, + "nonce": nonce, + "chainId": self.chain_id, + } + ) + + # EIP-1559 style fees + max_fee = self.w3.eth.gas_price + max_priority = self.w3.eth.max_priority_fee + tx["maxFeePerGas"] = max_fee + tx["maxPriorityFeePerGas"] = max_priority + tx["gas"] = self.w3.eth.estimate_gas(tx) + return tx + + def _send_tx(self, fn) -> str: + tx = self._build_tx(fn) + signed = self.account.sign_transaction(tx) + tx_hash = self.w3.eth.send_raw_transaction(signed.raw_transaction) + return tx_hash.hex() + + def _wait_receipt(self, tx_hash: str) -> Any: + return self.w3.eth.wait_for_transaction_receipt(tx_hash) + + def create_tournament(self, agent_count: int) -> TxResult: + tx_hash = self._send_tx(self.contract.functions.createTournament(agent_count)) + receipt = self._wait_receipt(tx_hash) + created = self.contract.events.TournamentCreated().process_receipt(receipt) + tournament_id = created[0]["args"]["tournamentId"] if created else None + return TxResult(tx_hash=tx_hash, contract_tournament_id=tournament_id) + + def close_betting(self, tournament_id: int) -> TxResult: + tx_hash = self._send_tx(self.contract.functions.closeBetting(tournament_id)) + return TxResult(tx_hash=tx_hash) + + def settle_tournament(self, tournament_id: int, winning_agent_id: int) -> TxResult: + tx_hash = self._send_tx( + self.contract.functions.settleTournament(tournament_id, winning_agent_id) + ) + return TxResult(tx_hash=tx_hash) + + def cancel_tournament(self, tournament_id: int) -> TxResult: + tx_hash = self._send_tx(self.contract.functions.cancelTournament(tournament_id)) + return TxResult(tx_hash=tx_hash) + + +def get_agonus_client() -> AgonusBettingClient: + rpc_url = os.getenv("SEPOLIA_RPC_URL") or os.getenv("RPC_URL", "") + private_key = os.getenv("SEPOLIA_PRIVATE_KEY") or os.getenv("PRIVATE_KEY", "") + contract_address = os.getenv("CONTRACT_ADDRESS", "") + abi_path = os.path.join( + os.path.dirname(__file__), "abi", "AgonusBetting.json" + ) + return AgonusBettingClient( + rpc_url=rpc_url, + private_key=private_key, + contract_address=contract_address, + abi_path=abi_path, + ) diff --git a/backend/app/schemas/bet.py b/backend/app/schemas/bet.py index 0f374ff..2edc92a 100644 --- a/backend/app/schemas/bet.py +++ b/backend/app/schemas/bet.py @@ -13,7 +13,13 @@ class BetBase(BaseModel): odds: Decimal -class BetCreate(BetBase): +class BetCreate(BaseModel): + """Schema for creating a bet - user_address comes from JWT token""" + agent_id: UUID + tournament_id: UUID + amount: Decimal + odds: Decimal = Decimal("1.0") + tx_hash: Optional[str] = None placed_at: Optional[datetime] = None settled: Optional[bool] = False payout: Optional[Decimal] = None diff --git a/backend/app/schemas/tournament.py b/backend/app/schemas/tournament.py index 60b448a..d3cb5bc 100644 --- a/backend/app/schemas/tournament.py +++ b/backend/app/schemas/tournament.py @@ -34,6 +34,16 @@ class TournamentContractLink(BaseModel): tx_hash: Optional[str] = None # For verification/tracking +# NEW: Schema for on-chain tournament creation +class TournamentOnchainCreate(BaseModel): + agent_ids: list[UUID] + + +# NEW: Schema for on-chain tournament settlement +class TournamentOnchainSettle(BaseModel): + winner_agent_id: UUID + + # Schema for responses (GET) class TournamentResponse(TournamentBase): id: UUID @@ -41,8 +51,9 @@ class TournamentResponse(TournamentBase): created_at: datetime winner_agent_id: Optional[UUID] = None - # NEW: Contract integration fields + # Contract integration fields contract_tournament_id: Optional[int] = None agent_contract_mapping: dict[str, int] = {} # {"agent-uuid": 1, "agent-uuid2": 2} + betting_closed: bool = False model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/scripts/add_contract_columns.py b/backend/app/scripts/add_contract_columns.py new file mode 100644 index 0000000..5abb609 --- /dev/null +++ b/backend/app/scripts/add_contract_columns.py @@ -0,0 +1,113 @@ +""" +Safely add smart contract columns to the tournament table. + +Adds 3 columns without touching existing data: + - contract_tournament_id (INTEGER, nullable) — links to on-chain tournament + - agent_contract_mapping (JSONB, default '{}') — maps agent UUIDs to contract IDs + - betting_closed (BOOLEAN, default false) — whether betting is closed + +Uses IF NOT EXISTS so it's safe to run multiple times. +After adding columns, populates mock values for existing tournaments. +""" +import asyncio +import os + +from dotenv import load_dotenv +import asyncpg + +load_dotenv() + +DATABASE_URL = os.getenv("DATABASE_URL", "") +PG_URL = DATABASE_URL.replace("postgresql+asyncpg://", "postgresql://") + + +async def migrate(): + if not DATABASE_URL: + print("Error: DATABASE_URL not found in environment.") + return + + conn = await asyncpg.connect(PG_URL, ssl="require") + + try: + # Step 1: Add columns (safe — IF NOT EXISTS) + print("Adding contract_tournament_id column...") + await conn.execute(""" + ALTER TABLE tournament + ADD COLUMN IF NOT EXISTS contract_tournament_id INTEGER DEFAULT NULL + """) + + print("Adding agent_contract_mapping column...") + await conn.execute(""" + ALTER TABLE tournament + ADD COLUMN IF NOT EXISTS agent_contract_mapping JSONB DEFAULT '{}'::jsonb + """) + + print("Adding betting_closed column...") + await conn.execute(""" + ALTER TABLE tournament + ADD COLUMN IF NOT EXISTS betting_closed BOOLEAN DEFAULT false + """) + + # Step 2: Add index on contract_tournament_id + print("Adding index on contract_tournament_id...") + await conn.execute(""" + CREATE INDEX IF NOT EXISTS ix_tournament_contract_id + ON tournament(contract_tournament_id) + """) + + # Step 3: Populate mock values for existing tournaments + print("\nPopulating mock values for existing tournaments...") + tournaments = await conn.fetch( + "SELECT id, name FROM tournament WHERE contract_tournament_id IS NULL" + ) + + for i, t in enumerate(tournaments): + mock_contract_id = i + 1 + + # Get agents in this tournament to build a mock mapping + agents = await conn.fetch( + "SELECT agent_id FROM agent_state WHERE tournament_id = $1", t["id"] + ) + + # If no agent_states, try getting all agents as fallback + if not agents: + agents = await conn.fetch("SELECT id as agent_id FROM agent") + + mock_mapping = {} + for j, agent in enumerate(agents): + mock_mapping[str(agent["agent_id"])] = j + 1 + + await conn.execute( + """ + UPDATE tournament + SET contract_tournament_id = $1, + agent_contract_mapping = $2::jsonb, + betting_closed = $3 + WHERE id = $4 + """, + mock_contract_id, + str(mock_mapping).replace("'", '"'), + False, + t["id"], + ) + print(f" {t['name']}: contract_id={mock_contract_id}, {len(mock_mapping)} agents mapped") + + print("\nMigration complete! Verifying...") + + # Step 4: Verify + rows = await conn.fetch( + "SELECT name, contract_tournament_id, agent_contract_mapping, betting_closed FROM tournament" + ) + for row in rows: + print(f" {row['name']}: contract_id={row['contract_tournament_id']}, " + f"mapping={row['agent_contract_mapping']}, betting_closed={row['betting_closed']}") + + except Exception as e: + print(f"Migration failed: {e}") + raise + finally: + await conn.close() + + +if __name__ == "__main__": + asyncio.run(migrate()) diff --git a/backend/app/scripts/reset_db.py b/backend/app/scripts/reset_db.py index 2204af8..92dce7d 100644 --- a/backend/app/scripts/reset_db.py +++ b/backend/app/scripts/reset_db.py @@ -2,11 +2,12 @@ import os # <--- 1. Was missing from dotenv import load_dotenv from sqlalchemy.ext.asyncio import create_async_engine +from sqlalchemy import text # 3. Correct Import: 'Bet', not 'Bets' from ..db.models import Base, Tournament, Agent, AgentState, Trade, Bet, PlanItem, AgentResearchArtifact -load_dotenv() +load_dotenv(dotenv_path=Path(__file__).resolve().parents[2] / ".env") DATABASE_URL = os.getenv("DATABASE_URL") DB_DISABLE_SSL = os.getenv("DB_DISABLE_SSL", "false").lower() == "true" @@ -14,7 +15,7 @@ async def reset_database(): if not DATABASE_URL: - print("❌ Error: DATABASE_URL not found in environment.") + print("Error: DATABASE_URL not found in environment.") return print("⚡ Connecting to database...") @@ -28,13 +29,16 @@ async def reset_database(): ) async with engine.begin() as conn: - print("🔥 Dropping all tables...") - await conn.run_sync(Base.metadata.drop_all) + print("Dropping all tables (CASCADE)...") + await conn.execute(text("DROP SCHEMA public CASCADE")) + await conn.execute(text("CREATE SCHEMA public")) + await conn.execute(text("GRANT ALL ON SCHEMA public TO neondb_owner")) + await conn.execute(text("GRANT ALL ON SCHEMA public TO public")) - print("🏗️ Creating new tables...") + print("Creating new tables...") await conn.run_sync(Base.metadata.create_all) - print("✅ Database reset successfully!") + print("Database reset successfully!") await engine.dispose() @@ -42,4 +46,4 @@ async def reset_database(): try: asyncio.run(reset_database()) except Exception as e: - print(f"❌ Error: {e}") + print(f"Error: {e}") diff --git a/tailwind.config.js b/backend/app/tools/__init__.py similarity index 100% rename from tailwind.config.js rename to backend/app/tools/__init__.py diff --git a/backend/app/tools/market_data.py b/backend/app/tools/market_data.py new file mode 100644 index 0000000..f780b0b --- /dev/null +++ b/backend/app/tools/market_data.py @@ -0,0 +1,72 @@ +# backend/app/tools/market_data.py + +import logging +from datetime import datetime +from typing import Dict, Optional + +logger = logging.getLogger(__name__) + +class MarketDataTool: + def __init__(self, api_base: str = None, api_key: str = None): + self.api_base = api_base or "https://api.coingecko.com/api/v3" + self.api_key = api_key + + # REQUIRED for our runtime agents + self.supported_tokens = { + "BTC": "bitcoin", + "ETH": "ethereum", + "WETH": "weth", + "CBBTC": "bitcoin", + } + + # cache + self._price_cache: Dict[str, float] = {} + self._cache_timestamp: Optional[datetime] = None + self._cache_ttl_seconds: int = 5 + + def _is_cache_valid(self) -> bool: + if not self._cache_timestamp: + return False + age = (datetime.utcnow() - self._cache_timestamp).total_seconds() + return age < self._cache_ttl_seconds + + # ------------------------------------------------------------------- + # MOCK PRICE FUNCTION for frontend development + # ------------------------------------------------------------------- + def get_price(self, token: str): + mock = { + "btc": {"usd": 42000}, + "eth": {"usd": 2300}, + "weth": {"usd": 2300}, + "cbbtc": {"usd": 42000}, + } + return mock.get(token.lower(), {"usd": 0}) + + def get_all_prices(self) -> Dict[str, float]: + if self._is_cache_valid(): + return self._price_cache.copy() + + prices = {} + for token in self.supported_tokens.keys(): + try: + price_data = self.get_price(token) + prices[token] = price_data.get("usd", 0) + except Exception as e: + logger.error(f"Price fetch fail for {token}: {e}") + prices[token] = self._price_cache.get(token, 0) + + self._price_cache = prices + self._cache_timestamp = datetime.utcnow() + return prices.copy() + + # ------------------------------------------------------------------- + # MOCK SENTIMENT + VOLUME + # ------------------------------------------------------------------- + def get_market_sentiment(self): + return { + "sentiment": "bullish", + "score": 0.72 + } + + def get_volume(self, token: str) -> float: + return 10000000.0 # mock volume for frontend usage diff --git a/backend/migrate_add_contract_columns.py b/backend/migrate_add_contract_columns.py new file mode 100644 index 0000000..7b11a2c --- /dev/null +++ b/backend/migrate_add_contract_columns.py @@ -0,0 +1,42 @@ +""" +One-time migration: Add smart-contract columns to the tournament table. + +Run with: python migrate_add_contract_columns.py +""" + +import asyncio +import os +from dotenv import load_dotenv +import asyncpg + +load_dotenv() + +DATABASE_URL = os.getenv("DATABASE_URL", "") + +# Convert SQLAlchemy URL to raw asyncpg URL +# "postgresql+asyncpg://..." → "postgresql://..." +PG_URL = DATABASE_URL.replace("postgresql+asyncpg://", "postgresql://") + +STATEMENTS = [ + "ALTER TABLE tournament ADD COLUMN IF NOT EXISTS contract_tournament_id INTEGER;", + "ALTER TABLE tournament ADD COLUMN IF NOT EXISTS agent_contract_mapping JSONB DEFAULT '{}';", + "ALTER TABLE tournament ADD COLUMN IF NOT EXISTS betting_closed BOOLEAN DEFAULT FALSE;", + # Index to match the SQLAlchemy model + "CREATE INDEX IF NOT EXISTS ix_tournament_contract_tournament_id ON tournament (contract_tournament_id);", +] + + +async def main(): + print(f"Connecting to database...") + conn = await asyncpg.connect(PG_URL, ssl="require") + try: + for stmt in STATEMENTS: + print(f" Running: {stmt}") + await conn.execute(stmt) + print("\nMigration complete! All 3 columns added to tournament table.") + finally: + await conn.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..52cacbf --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,2 @@ +[tool.pyright] +include = ["app"] diff --git a/backend/requirements.txt b/backend/requirements.txt index 67fdb87..933c2f0 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -89,6 +89,7 @@ MarkupSafe==3.0.3 mdurl==0.1.2 multidict==6.7.0 nest-asyncio==1.6.0 +ngrok==1.4.0 nilql==0.0.0a13 numpy==1.26.4 openai==1.109.1 @@ -160,7 +161,7 @@ tzdata==2025.2 uritemplate==4.2.0 urllib3==2.3.0 uvicorn==0.38.0 -uvloop==0.22.1 +# uvloop==0.22.1 # Linux/macOS only - not compatible with Windows vine==5.1.0 watchfiles==1.1.1 wcwidth==0.2.14 diff --git a/backend/tests/test_bets.py b/backend/tests/test_bets.py index 6b6195b..10f70db 100644 --- a/backend/tests/test_bets.py +++ b/backend/tests/test_bets.py @@ -94,7 +94,7 @@ async def test_get_bet(client, test_db): r = await client.get(f"/bets/{bet.id}") assert r.status_code == 200 - assert r.json()["amount"] == "150.00" + assert r.json()["amount"] == "150.00000000" @pytest.mark.anyio @@ -134,7 +134,7 @@ async def test_create_bet(client, test_db, mock_admin): r = await client.post("/bets/", json=body) assert r.status_code == 201, r.text created = r.json() - assert created["amount"] == "250.00" + assert created["amount"] == "250.00000000" assert "id" in created @@ -173,7 +173,7 @@ async def test_update_bet(client, test_db): r = await client.put(f"/bets/{bet.id}", json={"settled": True, "payout": 200.0}) assert r.status_code == 200, r.text assert r.json()["settled"] is True - assert r.json()["payout"] == "200.00" + assert r.json()["payout"] == "200.00000000" @pytest.mark.anyio @@ -211,7 +211,7 @@ async def test_settle_bet(client, test_db): r = await client.patch(f"/bets/{bet.id}/settle?payout=250.0") assert r.status_code == 200, r.text assert r.json()["settled"] is True - assert r.json()["payout"] == "250.00" + assert r.json()["payout"] == "250.00000000" @pytest.mark.anyio diff --git a/backend/tests/test_make_trade_tool.py b/backend/tests/test_make_trade_tool.py index 3f837b9..5baab8f 100644 --- a/backend/tests/test_make_trade_tool.py +++ b/backend/tests/test_make_trade_tool.py @@ -22,8 +22,6 @@ def mock_market_tool(): """Create a mock market data tool with fixed prices.""" market_tool = Mock() market_tool.get_price = Mock(side_effect=lambda token: { - "WETH": 3000.0, - "CBBTC": 60000.0, "ETH": 3000.0, "BTC": 60000.0, }.get(token.upper(), None)) @@ -48,7 +46,7 @@ def portfolio_with_holdings(): return Portfolio( agent_id="test_agent", cash=200.0, - holdings={"WETH": 0.1}, # 0.1 ETH worth ~$300 + holdings={"ETH": 0.1}, # 0.1 ETH worth ~$300 starting_val=500.0, holdings_val=300.0, total_value=500.0, @@ -90,57 +88,57 @@ class TestValidation: """Test trade validation logic.""" def test_valid_buy_trade(self, make_trade_tool): - is_valid, reason = make_trade_tool.validate_trade("BUY", "WETH", 100.0) + is_valid, reason = make_trade_tool.validate_trade("BUY", "ETH", 100.0) assert is_valid is True assert reason == "Trade validated" def test_valid_sell_trade(self, make_trade_tool_with_holdings): - is_valid, reason = make_trade_tool_with_holdings.validate_trade("SELL", "WETH", 0.05) + is_valid, reason = make_trade_tool_with_holdings.validate_trade("SELL", "ETH", 0.05) assert is_valid is True assert reason == "Trade validated" def test_invalid_action(self, make_trade_tool): - is_valid, reason = make_trade_tool.validate_trade("HOLD", "WETH", 100.0) + is_valid, reason = make_trade_tool.validate_trade("HOLD", "ETH", 100.0) assert is_valid is False assert "Invalid action" in reason def test_unsupported_token(self, make_trade_tool): - is_valid, reason = make_trade_tool.validate_trade("BUY", "DOGE", 100.0) + is_valid, reason = make_trade_tool.validate_trade("BUY", "SHIB", 100.0) assert is_valid is False assert "Unsupported token" in reason def test_insufficient_cash_for_buy(self, make_trade_tool): - is_valid, reason = make_trade_tool.validate_trade("BUY", "WETH", 600.0) + is_valid, reason = make_trade_tool.validate_trade("BUY", "ETH", 600.0) assert is_valid is False assert "Insufficient cash" in reason def test_insufficient_holdings_for_sell(self, make_trade_tool_with_holdings): - is_valid, reason = make_trade_tool_with_holdings.validate_trade("SELL", "WETH", 1.0) + is_valid, reason = make_trade_tool_with_holdings.validate_trade("SELL", "ETH", 1.0) assert is_valid is False - assert "Insufficient WETH" in reason + assert "Insufficient ETH" in reason def test_zero_amount(self, make_trade_tool): - is_valid, reason = make_trade_tool.validate_trade("BUY", "WETH", 0.0) + is_valid, reason = make_trade_tool.validate_trade("BUY", "ETH", 0.0) assert is_valid is False assert "Invalid amount" in reason def test_negative_amount(self, make_trade_tool): - is_valid, reason = make_trade_tool.validate_trade("BUY", "WETH", -100.0) + is_valid, reason = make_trade_tool.validate_trade("BUY", "ETH", -100.0) assert is_valid is False assert "Invalid amount" in reason def test_risk_limit_exceeded(self, make_trade_tool): # With risk_score=0.5, max trade is 50% of $500 = $250 - is_valid, reason = make_trade_tool.validate_trade("BUY", "WETH", 300.0, risk_score=0.5) + is_valid, reason = make_trade_tool.validate_trade("BUY", "ETH", 300.0, risk_score=0.5) assert is_valid is False assert "exceeds risk limit" in reason def test_case_insensitive_action(self, make_trade_tool): - is_valid, _ = make_trade_tool.validate_trade("buy", "WETH", 100.0) + is_valid, _ = make_trade_tool.validate_trade("buy", "ETH", 100.0) assert is_valid is True def test_case_insensitive_token(self, make_trade_tool): - is_valid, _ = make_trade_tool.validate_trade("BUY", "weth", 100.0) + is_valid, _ = make_trade_tool.validate_trade("BUY", "eth", 100.0) assert is_valid is True @@ -156,7 +154,7 @@ class TestBuyTrades: async def test_basic_buy(self, make_trade_tool): trade = await make_trade_tool.execute_trade( action="BUY", - token="WETH", + token="ETH", amount=150.0, # $150 USDC confidence=0.8, summary="Test buy", @@ -164,7 +162,7 @@ async def test_basic_buy(self, make_trade_tool): # Check trade details assert trade.action == "BUY" - assert trade.token == "WETH" + assert trade.token == "ETH" assert trade.price == 3000.0 assert trade.qty == pytest.approx(0.05) # $150 / $3000 = 0.05 ETH assert trade.confidence == 0.8 @@ -173,43 +171,43 @@ async def test_basic_buy(self, make_trade_tool): # Check portfolio updates assert make_trade_tool.portfolio.cash == pytest.approx(350.0) # $500 - $150 - assert make_trade_tool.portfolio.holdings["WETH"] == pytest.approx(0.05) + assert make_trade_tool.portfolio.holdings["ETH"] == pytest.approx(0.05) assert make_trade_tool.portfolio.num_trades == 1 @pytest.mark.asyncio async def test_buy_adds_to_existing_holdings(self, make_trade_tool_with_holdings): await make_trade_tool_with_holdings.execute_trade( action="BUY", - token="WETH", + token="ETH", amount=60.0, # $60 USDC = 0.02 ETH confidence=0.7, summary="Add to position", ) # Should add to existing 0.1 ETH - assert make_trade_tool_with_holdings.portfolio.holdings["WETH"] == pytest.approx(0.12) + assert make_trade_tool_with_holdings.portfolio.holdings["ETH"] == pytest.approx(0.12) assert make_trade_tool_with_holdings.portfolio.cash == pytest.approx(140.0) # $200 - $60 @pytest.mark.asyncio async def test_buy_different_token(self, make_trade_tool_with_holdings): await make_trade_tool_with_holdings.execute_trade( action="BUY", - token="CBBTC", + token="BTC", amount=60.0, # $60 USDC confidence=0.75, summary="Diversify into BTC", ) # Should have both tokens - assert "WETH" in make_trade_tool_with_holdings.portfolio.holdings - assert "CBBTC" in make_trade_tool_with_holdings.portfolio.holdings - assert make_trade_tool_with_holdings.portfolio.holdings["CBBTC"] == pytest.approx(0.001) # $60 / $60000 + assert "ETH" in make_trade_tool_with_holdings.portfolio.holdings + assert "BTC" in make_trade_tool_with_holdings.portfolio.holdings + assert make_trade_tool_with_holdings.portfolio.holdings["BTC"] == pytest.approx(0.001) # $60 / $60000 @pytest.mark.asyncio async def test_buy_updates_metrics(self, make_trade_tool): await make_trade_tool.execute_trade( action="BUY", - token="WETH", + token="ETH", amount=100.0, confidence=0.8, summary="Test", @@ -218,7 +216,7 @@ async def test_buy_updates_metrics(self, make_trade_tool): status = make_trade_tool.get_portfolio_status() assert status["num_trades"] == 1 assert status["cash"] == pytest.approx(400.0) - assert "WETH" in status["holdings"] + assert "ETH" in status["holdings"] # ============================================================================ @@ -233,7 +231,7 @@ class TestSellTrades: async def test_basic_sell(self, make_trade_tool_with_holdings): trade = await make_trade_tool_with_holdings.execute_trade( action="SELL", - token="WETH", + token="ETH", amount=0.05, # Sell half (0.05 of 0.1 ETH) confidence=0.75, summary="Take profit", @@ -241,26 +239,26 @@ async def test_basic_sell(self, make_trade_tool_with_holdings): # Check trade details assert trade.action == "SELL" - assert trade.token == "WETH" + assert trade.token == "ETH" assert trade.qty == 0.05 assert trade.price == 3000.0 # Check portfolio updates - assert make_trade_tool_with_holdings.portfolio.holdings["WETH"] == pytest.approx(0.05) + assert make_trade_tool_with_holdings.portfolio.holdings["ETH"] == pytest.approx(0.05) assert make_trade_tool_with_holdings.portfolio.cash == pytest.approx(350.0) # $200 + $150 @pytest.mark.asyncio async def test_sell_all_removes_from_holdings(self, make_trade_tool_with_holdings): await make_trade_tool_with_holdings.execute_trade( action="SELL", - token="WETH", + token="ETH", amount=0.1, # Sell all confidence=0.9, summary="Exit position", ) # Holdings should be empty for WETH - assert "WETH" not in make_trade_tool_with_holdings.portfolio.holdings + assert "ETH" not in make_trade_tool_with_holdings.portfolio.holdings assert make_trade_tool_with_holdings.portfolio.cash == pytest.approx(500.0) # $200 + $300 @pytest.mark.asyncio @@ -268,7 +266,7 @@ async def test_sell_calculates_realized_pnl(self, make_trade_tool): # First buy at $3000 await make_trade_tool.execute_trade( action="BUY", - token="WETH", + token="ETH", amount=300.0, # Buy 0.1 ETH at $3000 confidence=0.8, summary="Initial buy", @@ -280,7 +278,7 @@ async def test_sell_calculates_realized_pnl(self, make_trade_tool): # Sell at higher price trade = await make_trade_tool.execute_trade( action="SELL", - token="WETH", + token="ETH", amount=0.1, # Sell all confidence=0.9, summary="Take profit", @@ -296,7 +294,7 @@ async def test_sell_losing_trade(self, make_trade_tool): # First buy at $3000 await make_trade_tool.execute_trade( action="BUY", - token="WETH", + token="ETH", amount=300.0, confidence=0.8, summary="Initial buy", @@ -308,7 +306,7 @@ async def test_sell_losing_trade(self, make_trade_tool): # Sell at lower price trade = await make_trade_tool.execute_trade( action="SELL", - token="WETH", + token="ETH", amount=0.1, confidence=0.6, summary="Cut losses", @@ -331,19 +329,19 @@ class TestFIFOPnL: @pytest.mark.asyncio async def test_fifo_multiple_buys(self, make_trade_tool): # Buy 1: 0.05 ETH at $3000 - await make_trade_tool.execute_trade("BUY", "WETH", 150.0, 0.8, "First buy") + await make_trade_tool.execute_trade("BUY", "ETH", 150.0, 0.8, "First buy") # Change price to $3200 make_trade_tool.market_tool.get_price = Mock(return_value=3200.0) # Buy 2: 0.05 ETH at $3200 - await make_trade_tool.execute_trade("BUY", "WETH", 160.0, 0.8, "Second buy") + await make_trade_tool.execute_trade("BUY", "ETH", 160.0, 0.8, "Second buy") # Change price to $3500 make_trade_tool.market_tool.get_price = Mock(return_value=3500.0) # Sell first lot (FIFO - should use $3000 cost basis) - trade = await make_trade_tool.execute_trade("SELL", "WETH", 0.05, 0.9, "Sell first lot") + trade = await make_trade_tool.execute_trade("SELL", "ETH", 0.05, 0.9, "Sell first lot") # PnL: (3500 - 3000) * 0.05 = $25 assert trade.realized_pnl == pytest.approx(25.0) @@ -351,19 +349,19 @@ async def test_fifo_multiple_buys(self, make_trade_tool): @pytest.mark.asyncio async def test_fifo_partial_lot(self, make_trade_tool): # Buy 0.1 ETH at $3000 - await make_trade_tool.execute_trade("BUY", "WETH", 300.0, 0.8, "Initial buy") + await make_trade_tool.execute_trade("BUY", "ETH", 300.0, 0.8, "Initial buy") # Change price to $3300 make_trade_tool.market_tool.get_price = Mock(return_value=3300.0) # Sell half - trade = await make_trade_tool.execute_trade("SELL", "WETH", 0.05, 0.85, "Partial sell") + trade = await make_trade_tool.execute_trade("SELL", "ETH", 0.05, 0.85, "Partial sell") # PnL: (3300 - 3000) * 0.05 = $15 assert trade.realized_pnl == pytest.approx(15.0) # Sell remaining half - trade2 = await make_trade_tool.execute_trade("SELL", "WETH", 0.05, 0.85, "Sell rest") + trade2 = await make_trade_tool.execute_trade("SELL", "ETH", 0.05, 0.85, "Sell rest") # PnL: (3300 - 3000) * 0.05 = $15 assert trade2.realized_pnl == pytest.approx(15.0) @@ -380,7 +378,7 @@ class TestPortfolioMetrics: @pytest.mark.asyncio async def test_roi_calculation(self, make_trade_tool): # Buy some ETH - await make_trade_tool.execute_trade("BUY", "WETH", 250.0, 0.8, "Buy") + await make_trade_tool.execute_trade("BUY", "ETH", 250.0, 0.8, "Buy") # Price goes up 10% make_trade_tool.market_tool.get_price = Mock(return_value=3300.0) @@ -394,13 +392,13 @@ async def test_roi_calculation(self, make_trade_tool): @pytest.mark.asyncio async def test_win_rate_calculation(self, make_trade_tool): # Execute trades - await make_trade_tool.execute_trade("BUY", "WETH", 150.0, 0.8, "Buy 1") + await make_trade_tool.execute_trade("BUY", "ETH", 150.0, 0.8, "Buy 1") make_trade_tool.market_tool.get_price = Mock(return_value=3500.0) - await make_trade_tool.execute_trade("SELL", "WETH", 0.025, 0.9, "Win") # Winner + await make_trade_tool.execute_trade("SELL", "ETH", 0.025, 0.9, "Win") # Winner make_trade_tool.market_tool.get_price = Mock(return_value=2800.0) - await make_trade_tool.execute_trade("SELL", "WETH", 0.025, 0.6, "Loss") # Loser + await make_trade_tool.execute_trade("SELL", "ETH", 0.025, 0.6, "Loss") # Loser # 1 win, 1 loss out of 3 trades (1 buy + 2 sells) # But win_rate is based on winning vs losing trades only @@ -419,7 +417,7 @@ def test_get_portfolio_status(self, make_trade_tool_with_holdings): assert "win_rate" in status assert status["cash"] == 200.0 - assert status["holdings"]["WETH"] == 0.1 + assert status["holdings"]["ETH"] == 0.1 # ============================================================================ @@ -435,7 +433,7 @@ async def test_validation_error_raises_exception(self, make_trade_tool): with pytest.raises(MakeTradeToolError) as exc_info: await make_trade_tool.execute_trade( action="BUY", - token="WETH", + token="ETH", amount=1000.0, # More than available confidence=0.8, summary="Should fail", @@ -449,7 +447,7 @@ async def test_price_fetch_error(self, make_trade_tool): with pytest.raises(MakeTradeToolError) as exc_info: await make_trade_tool.execute_trade( action="BUY", - token="WETH", + token="ETH", amount=100.0, confidence=0.8, summary="Should fail", @@ -480,7 +478,7 @@ async def test_trade_saved_to_database(self, base_portfolio, mock_market_tool): tournament_uuid=uuid4(), ) - await tool.execute_trade("BUY", "WETH", 100.0, 0.8, "Test") + await tool.execute_trade("BUY", "ETH", 100.0, 0.8, "Test") # Verify database was called mock_db.save_trade.assert_called_once() @@ -501,6 +499,6 @@ async def test_database_error_doesnt_fail_trade(self, base_portfolio, mock_marke ) # Trade should still succeed even if DB fails - trade = await tool.execute_trade("BUY", "WETH", 100.0, 0.8, "Test") + trade = await tool.execute_trade("BUY", "ETH", 100.0, 0.8, "Test") assert trade is not None assert tool.portfolio.cash == pytest.approx(400.0) diff --git a/backend/tests/tests.http b/backend/tests/tests.http new file mode 100644 index 0000000..fffc92e --- /dev/null +++ b/backend/tests/tests.http @@ -0,0 +1,95 @@ +### ============================================ +### 🔥 BACKEND HEALTH CHECK +### ============================================ + +GET http://localhost:8000/ +Accept: application/json + + +### ============================================ +### 🔥 RUNTIME AGENTS — LIST ALL +### ============================================ + +GET http://localhost:8000/runtime/agents/ +Accept: application/json + + +### ============================================ +### 🔥 RUNTIME AGENT PORTFOLIO +### ============================================ + +GET http://localhost:8000/runtime/agents/1/portfolio +Accept: application/json + +### +GET http://localhost:8000/runtime/agents/2/portfolio +Accept: application/json + + +### ============================================ +### 🔥 RUNTIME AGENT TRADES +### ============================================ + +GET http://localhost:8000/runtime/agents/1/trades +Accept: application/json + +### +GET http://localhost:8000/runtime/agents/2/trades +Accept: application/json + + +### ============================================ +### 🔥 RUNTIME MARKET PRICES +### ============================================ + +GET http://localhost:8000/runtime/agents/market/prices +Accept: application/json + + +### ============================================ +### 🔥 TOURNAMENT API (if DB enabled) +### ============================================ + +GET http://localhost:8000/tournaments/ +Accept: application/json + +### +GET http://localhost:8000/tournaments/1 +Accept: application/json + + +### ============================================ +### 🔥 AGENTS API (DB agents, NOT runtime agents) +### ============================================ + +GET http://localhost:8000/agents/ +Accept: application/json + +### +GET http://localhost:8000/agents/1 +Accept: application/json + + +### ============================================ +### 🔥 TRADES API (DB trades) +### ============================================ + +GET http://localhost:8000/trades/ +Accept: application/json + + +### ============================================ +### 🔥 BETS API +### ============================================ + +GET http://localhost:8000/bets/ +Accept: application/json + + +### ============================================ +### 🔥 MARKET DATA (DB-driven) +### ============================================ + +GET http://localhost:8000/market-data/prices +Accept: application/json + diff --git a/frontend/app/admin/layout.tsx b/frontend/app/admin/layout.tsx new file mode 100644 index 0000000..e34dc58 --- /dev/null +++ b/frontend/app/admin/layout.tsx @@ -0,0 +1,165 @@ +"use client"; + +import AdminSidebar from "@/app/components/admin/layout/AdminSidebar"; +import Link from "next/link"; +import { useState } from "react"; +import CreateTournamentModal, { TournamentFormData } from "@/app/components/admin/tournaments/CreateTournamentModal"; +import CreateAgentModal, { AgentFormData } from "@/app/components/admin/agents/CreateAgentModal"; +import { useWalletAuth } from "@/src/hooks/useWalletAuth"; +import { ConnectWallet } from "@/src/components/wallet/ConnectWallet"; +import { useCreateTournament } from "@/src/hooks/useTournaments"; +import { useCreateAgent } from "@/src/hooks/useAgents"; +import { Loader2, ShieldAlert, ShieldX } from "lucide-react"; +import toast from "react-hot-toast"; +import "@/app/styles/datepicker.css"; + +export default function AdminLayout({ + children, +}: { + children: React.ReactNode; +}) { + const [isCreateTournamentModalOpen, setIsCreateTournamentModalOpen] = useState(false); + const [isCreateAgentModalOpen, setIsCreateAgentModalOpen] = useState(false); + + const { isConnected, isAuthenticated, isSigningIn, signIn, isAdmin, address } = useWalletAuth(); + const createTournament = useCreateTournament(); + const createAgent = useCreateAgent(); + + const handleCreateTournament = async (data: TournamentFormData) => { + try { + await createTournament.mutateAsync({ + name: data.name, + start_date: data.start_date.toISOString(), + end_date: data.end_date.toISOString(), + prize_pool: data.prize_pool, + agent_ids: [], + }); + setIsCreateTournamentModalOpen(false); + toast.success("Tournament created"); + } catch (err) { + toast.error((err as Error).message || "Failed to create tournament"); + } + }; + + const handleCreateAgent = async (data: AgentFormData) => { + try { + await createAgent.mutateAsync({ + name: data.name, + personality: data.personality, + strategy_type: data.strategy_type, + }); + setIsCreateAgentModalOpen(false); + toast.success("Agent created"); + } catch (err) { + toast.error((err as Error).message || "Failed to create agent"); + } + }; + + // Gate 1: Wallet not connected + if (!isConnected) { + return ( +
+
+ +

Admin Access Required

+

Connect your admin wallet to access the dashboard.

+ +
+
+ ); + } + + // Gate 2: Wallet connected but not signed in + if (!isAuthenticated) { + return ( +
+
+ +

Sign In Required

+

+ Sign a message with your wallet to verify admin access. +

+ +
+
+ ); + } + + // Gate 3: Signed in but not admin role + if (!isAdmin) { + return ( +
+
+ +

Access Denied

+

+ Wallet {address} does not have admin privileges. +

+ + Back to Home + +
+
+ ); + } + + return ( +
+ {/* Animated gradient glow effect */} +
+
+ + {/* Checkered pattern overlay */} +
+ + {/* Sidebar */} +
+ setIsCreateTournamentModalOpen(true)} + onCreateAgent={() => setIsCreateAgentModalOpen(true)} + /> +
+ + {/* Main Content */} +
+
+ {children} +
+
+ + setIsCreateTournamentModalOpen(false)} + onSubmit={handleCreateTournament} + /> + + setIsCreateAgentModalOpen(false)} + onSubmit={handleCreateAgent} + /> +
+ ); +} diff --git a/frontend/app/admin/page.tsx b/frontend/app/admin/page.tsx new file mode 100644 index 0000000..839a06f --- /dev/null +++ b/frontend/app/admin/page.tsx @@ -0,0 +1,842 @@ +"use client"; + +import { useState, useMemo, useEffect } from "react"; +import { Search, Link2, XCircle, Trophy, Ban, Loader2, CheckCircle, AlertCircle, Calendar, DollarSign, Users, Plus, X, ArrowRight, Play } from "lucide-react"; +import { motion, AnimatePresence } from "framer-motion"; +import { formatDistanceToNow } from "date-fns"; +import { useTournaments } from "@/src/hooks/useTournaments"; +import { useAgents } from "@/src/hooks/useAgents"; +import { useTournamentAgentStates } from "@/src/hooks/useAgentStates"; +import { findAgentById } from "@/src/util/findAgentById"; +import { + useCreateOnchainTournament, + useStartTournament, + useCloseBettingOnchain, + useSettleTournamentOnchain, + useCancelTournamentOnchain, +} from "@/src/hooks/useAdminOnchain"; +import { useAuthStore } from "@/src/store/useAuthStore"; + +// Helper to format dates +const formatDate = (date: string) => { + return new Date(date).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); +}; + +// Helper to format currency +const formatCurrency = (value: string | number) => { + return `$${Number(value).toLocaleString()}`; +}; + +// Extract short, readable error message from contract/API errors +const getShortError = (err: unknown): string => { + const raw = (err as Error)?.message || 'Unknown error'; + // Try to extract "execution reverted: " pattern + const revertMatch = raw.match(/execution reverted:\s*([^"',]+)/i); + if (revertMatch) return revertMatch[1].trim(); + // Strip hex data and long hashes + const cleaned = raw.replace(/0x[a-fA-F0-9]{10,}/g, '').replace(/\s{2,}/g, ' ').trim(); + return cleaned.length > 80 ? cleaned.slice(0, 80) + '...' : cleaned; +}; + +// Generate DiceBear avatar URL +const getAvatarUrl = (name: string) => + `https://api.dicebear.com/7.x/bottts/svg?seed=${encodeURIComponent(name)}`; + +export default function AdminDashboard() { + const { isAuthenticated, role } = useAuthStore(); + const [agentSearch, setAgentSearch] = useState(""); + const [tournamentFilter, setTournamentFilter] = useState<"all" | "upcoming" | "live" | "completed">("all"); + const [selectedTournamentId, setSelectedTournamentId] = useState(null); + const [selectedAgentsForOnchain, setSelectedAgentsForOnchain] = useState([]); + const [winnerAgentId, setWinnerAgentId] = useState(""); + const [actionMessage, setActionMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + const [showOnchainPanel, setShowOnchainPanel] = useState(false); + + // Real API data + const { data: tournaments, isLoading: tournamentsLoading } = useTournaments(); + const { data: agents, isLoading: agentsLoading } = useAgents(); + const { data: agentStates } = useTournamentAgentStates(selectedTournamentId || ""); + + // On-chain mutations + const createOnchain = useCreateOnchainTournament(); + const startTournament = useStartTournament(); + const closeBetting = useCloseBettingOnchain(); + const settleTournament = useSettleTournamentOnchain(); + const cancelTournament = useCancelTournamentOnchain(); + + const isLoading = tournamentsLoading || agentsLoading; + + // Lock body scroll when panel is open + useEffect(() => { + document.body.style.overflow = showOnchainPanel ? "hidden" : ""; + return () => { document.body.style.overflow = ""; }; + }, [showOnchainPanel]); + + // Filter tournaments by status + const filteredTournaments = useMemo(() => { + if (!tournaments) return []; + if (tournamentFilter === "all") return tournaments; + return tournaments.filter(t => t.status?.toLowerCase() === tournamentFilter); + }, [tournaments, tournamentFilter]); + + // Filter agents by search + const filteredAgents = useMemo(() => { + if (!agents) return []; + if (!agentSearch) return agents; + return agents.filter(agent => + agent.name.toLowerCase().includes(agentSearch.toLowerCase()) + ); + }, [agents, agentSearch]); + + // Available agents = all agents minus already-selected ones + const availableAgents = useMemo(() => { + if (!agents) return []; + return agents.filter(a => !selectedAgentsForOnchain.includes(String(a.id))); + }, [agents, selectedAgentsForOnchain]); + + // Assigned agents in order, with their data + const assignedAgents = useMemo(() => { + if (!agents) return []; + return selectedAgentsForOnchain + .map(id => findAgentById(agents, id)) + .filter(Boolean) as typeof agents; + }, [agents, selectedAgentsForOnchain]); + + const selectedTournament = tournaments?.find(t => t.id === selectedTournamentId); + + // Add agent to assignment list + const addAgent = (agentId: string) => { + setSelectedAgentsForOnchain(prev => [...prev, agentId]); + }; + + // Remove agent from assignment list + const removeAgent = (agentId: string) => { + setSelectedAgentsForOnchain(prev => prev.filter(id => id !== agentId)); + }; + + // Handle on-chain create + const handleCreateOnchain = async () => { + if (!selectedTournamentId || selectedAgentsForOnchain.length < 2) { + setActionMessage({ type: 'error', text: 'Select at least 2 agents to create on-chain tournament' }); + return; + } + + try { + const result = await createOnchain.mutateAsync({ + tournamentId: selectedTournamentId, + agentIds: selectedAgentsForOnchain, + }); + setActionMessage({ type: 'success', text: `Tournament linked on-chain! TX: ${result.tx_hash.slice(0, 10)}...` }); + setSelectedAgentsForOnchain([]); + } catch (err) { + setActionMessage({ type: 'error', text: getShortError(err) }); + } + }; + + // Handle start tournament + const handleStartTournament = async () => { + if (!selectedTournamentId) return; + try { + const result = await startTournament.mutateAsync(selectedTournamentId); + setActionMessage({ type: 'success', text: `Tournament started! ${result.agents_count} agents initialized.` }); + } catch (err) { + setActionMessage({ type: 'error', text: getShortError(err) }); + } + }; + + // Handle close betting + const handleCloseBetting = async () => { + if (!selectedTournamentId) return; + try { + const result = await closeBetting.mutateAsync(selectedTournamentId); + setActionMessage({ type: 'success', text: `Betting closed! TX: ${result.tx_hash.slice(0, 10)}...` }); + } catch (err) { + setActionMessage({ type: 'error', text: getShortError(err) }); + } + }; + + // Handle settle tournament + const handleSettleTournament = async () => { + if (!selectedTournamentId || !winnerAgentId) { + setActionMessage({ type: 'error', text: 'Select a winner agent to settle' }); + return; + } + try { + const result = await settleTournament.mutateAsync({ + tournamentId: selectedTournamentId, + winnerAgentId, + }); + setActionMessage({ type: 'success', text: `Tournament settled! TX: ${result.tx_hash.slice(0, 10)}...` }); + setWinnerAgentId(""); + } catch (err) { + setActionMessage({ type: 'error', text: getShortError(err) }); + } + }; + + // Handle cancel tournament + const handleCancelTournament = async () => { + if (!selectedTournamentId) return; + if (!confirm("Are you sure you want to cancel this tournament? All bets will be refundable.")) return; + try { + const result = await cancelTournament.mutateAsync(selectedTournamentId); + setActionMessage({ type: 'success', text: `Tournament cancelled! TX: ${result.tx_hash.slice(0, 10)}...` }); + } catch (err) { + setActionMessage({ type: 'error', text: getShortError(err) }); + } + }; + + const anyMutationPending = createOnchain.isPending || startTournament.isPending || closeBetting.isPending || settleTournament.isPending || cancelTournament.isPending; + + const getStatusBadge = (status: string) => { + const statusLower = status?.toLowerCase() || 'upcoming'; + const colors: Record = { + upcoming: 'bg-blue-500/20 text-blue-400 border-blue-500/30', + live: 'bg-green-500/20 text-green-400 border-green-500/30', + completed: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30', + }; + return colors[statusLower] || colors.upcoming; + }; + + // Auth gate - only admins can access this page + if (!isAuthenticated || role !== "admin") { + return ( +
+
+ +

Admin Access Required

+

Please sign in with an admin wallet to access this page.

+
+
+ ); + } + + if (isLoading) { + return ( +
+ + Loading data... +
+ ); + } + + return ( +
+ {/* Header */} +
+

Admin Dashboard

+

Manage tournaments, agents, and on-chain operations

+
+ + {/* Action Message */} + + {actionMessage && ( + + {actionMessage.type === 'success' ? : } + {actionMessage.text} + + + )} + + + {/* ============ TOURNAMENTS SECTION ============ */} +
+
+
+

Tournaments

+

View and manage all tournaments

+
+
+ {tournaments?.length || 0} total +
+
+ + {/* Filter Buttons */} +
+ {['all', 'upcoming', 'live', 'completed'].map((filter) => ( + + ))} +
+ + {/* Tournament Cards */} +
+ {filteredTournaments.map((tournament) => { + const agentCount = tournament.agent_contract_mapping + ? Object.keys(tournament.agent_contract_mapping).length + : 0; + const now = new Date(); + const startDate = new Date(tournament.start_date); + const endDate = new Date(tournament.end_date); + const isUpcoming = tournament.status?.toLowerCase() === 'upcoming'; + const isLive = tournament.status?.toLowerCase() === 'live'; + const isCompleted = tournament.status?.toLowerCase() === 'completed'; + + // Time display + let timeDisplay = ''; + if (isUpcoming && startDate > now) { + timeDisplay = `Starts ${formatDistanceToNow(startDate, { addSuffix: true })}`; + } else if (isLive && endDate > now) { + timeDisplay = `Ends ${formatDistanceToNow(endDate, { addSuffix: true })}`; + } else if (isCompleted) { + timeDisplay = `Ended ${formatDistanceToNow(endDate, { addSuffix: true })}`; + } + + return ( +
{ + setSelectedTournamentId(tournament.id); + setSelectedAgentsForOnchain([]); + setShowOnchainPanel(true); + }} + > +
+ {/* Left: Tournament Info */} +
+
+

{tournament.name}

+ + {tournament.status} + + {tournament.contract_tournament_id ? ( + + On-chain #{tournament.contract_tournament_id} + + ) : ( + + Not linked + + )} +
+ + {/* Time Status */} + {timeDisplay && ( +

+ {timeDisplay} +

+ )} + +
+ + + {formatDate(tournament.start_date)} - {formatDate(tournament.end_date)} + + + + {formatCurrency(tournament.prize_pool)} prize + + + + {agentCount} agents + +
+
+ + {/* Right: Stats */} +
+

{formatCurrency(tournament.prize_pool)}

+

Prize Pool

+ {tournament.winner_agent_id && ( +
+

+ Winner Selected +

+
+ )} +
+
+ + {/* Agent Mapping Info */} + {tournament.agent_contract_mapping && Object.keys(tournament.agent_contract_mapping).length > 0 && ( +
+

Mapped Agents ({Object.keys(tournament.agent_contract_mapping).length}):

+
+ {Object.entries(tournament.agent_contract_mapping).slice(0, 6).map(([agentId, contractId]) => { + const agent = findAgentById(agents, agentId); + return ( +
+ {agent?.name + {agent?.name || agentId.slice(0, 8)} + #{contractId} +
+ ); + })} + {Object.keys(tournament.agent_contract_mapping).length > 6 && ( + + +{Object.keys(tournament.agent_contract_mapping).length - 6} more + + )} +
+
+ )} +
+ ); + })} + + {filteredTournaments.length === 0 && ( +
+

No tournaments found for this filter.

+
+ )} +
+
+ + {/* ============ AGENTS SECTION ============ */} +
+
+
+

Agents

+

Manage and view all AI trading agents

+
+ {agents?.length || 0} total +
+ + {/* Search Bar */} +
+ + setAgentSearch(e.target.value)} + className="w-full bg-yellow-500/90 backdrop-blur-sm text-slate-900 placeholder:text-slate-700 rounded-xl pl-12 pr-4 py-3 font-medium focus:outline-none focus:ring-2 focus:ring-yellow-400" + /> +
+ + {/* Agent Grid */} +
+ {filteredAgents.map((agent, index) => { + // Extract stats from the agent's stats JSON field + const stats = agent.stats as Record || {}; + const totalTournaments = stats.total_tournaments ?? 0; + const wins = stats.wins ?? 0; + const winRate = stats.win_rate ?? 0; + const avgRank = stats.avg_rank ?? 0; + const totalTrades = stats.total_trades ?? 0; + + return ( + + {/* Header: Avatar + Name */} +
+
+ {agent.name} +
+
+

{agent.name}

+

{agent.strategy_type?.replace('_', ' ') || 'Agent'}

+
+
+ + {/* Personality */} +
+

Personality

+

{agent.personality || 'No personality defined'}

+
+ + {/* Stats Grid */} +
+
+

Tournaments

+

{totalTournaments}

+
+
+

Wins

+

{wins}

+
+
+

Win Rate

+

{(winRate * 100).toFixed(0)}%

+
+
+

Avg Rank

+

{avgRank > 0 ? `#${avgRank.toFixed(1)}` : '-'}

+
+
+ + {/* Additional Stats */} +
+ Total Trades: {totalTrades} + ID: {agent.id.slice(0, 8)}... +
+
+ ); + })} + + {filteredAgents.length === 0 && ( +

No agents found

+ )} +
+
+ + {/* ============ ON-CHAIN ACTIONS PANEL (Slide-in) ============ */} + + {showOnchainPanel && selectedTournament && ( + <> + {/* Backdrop */} + setShowOnchainPanel(false)} + className="fixed inset-0 bg-black/60 backdrop-blur-sm z-40" + /> + + {/* Panel */} + +
+ {/* Header */} +
+

On-Chain Actions

+ +
+ + {/* Read-only Tournament Summary */} +
+

{selectedTournament.name}

+
+
+ Status: + + {selectedTournament.status} + +
+
+ Prize Pool: + {formatCurrency(selectedTournament.prize_pool)} +
+
+ Dates: + {formatDate(selectedTournament.start_date)} - {formatDate(selectedTournament.end_date)} +
+ {selectedTournament.contract_tournament_id != null && ( +
+ Contract ID: + + + #{selectedTournament.contract_tournament_id} + +
+ )} +
+
+ + {/* Actions */} +
+ + {/* ===== NOT LINKED: Agent Assignment + Create ===== */} + {!selectedTournament.contract_tournament_id && ( +
+

+ Link Tournament On-Chain +

+ + {/* Assigned Agents (right side concept) */} +
+

+ Assigned Agents ({assignedAgents.length}) + {assignedAgents.length < 2 && - need at least 2} +

+ + {assignedAgents.length === 0 ? ( +
+ No agents assigned yet. Add agents below. +
+ ) : ( +
+ {assignedAgents.map((agent, idx) => ( +
+ + #{idx + 1} + + {agent.name} + {agent.name} + {agent.strategy_type?.replace('_', ' ')} + +
+ ))} +
+ )} +
+ + {/* Available Agents (left side concept) */} + {availableAgents.length > 0 && ( +
+

Available Agents

+
+ {availableAgents.map(agent => ( +
+ {agent.name} + {agent.name} + {agent.strategy_type?.replace('_', ' ')} + +
+ ))} +
+
+ )} + + +
+ )} + + {/* ===== LINKED: Read-only mapping + Lifecycle actions ===== */} + {selectedTournament.contract_tournament_id != null && ( + <> + {/* Read-only Agent Mapping */} + {selectedTournament.agent_contract_mapping && Object.keys(selectedTournament.agent_contract_mapping).length > 0 && ( +
+

+ On-Chain Tournament #{selectedTournament.contract_tournament_id} +

+

Agent Mapping (read-only)

+
+ {Object.entries(selectedTournament.agent_contract_mapping) + .sort(([, a], [, b]) => a - b) + .map(([agentId, contractId]) => { + const agent = findAgentById(agents, agentId); + return ( +
+ #{contractId} + {agent?.name + {agent?.name || agentId.slice(0, 8)} +
+ ); + })} +
+
+ )} + + {/* Lifecycle Status Indicator */} +
+

On-Chain Lifecycle

+
+ Linked + → + + {selectedTournament.betting_closed ? 'Betting Closed' : 'Betting Open'} + + → + + {selectedTournament.winner_agent_id ? 'Settled' : 'Pending'} + +
+
+ + {/* Start Tournament - only show if linked but not yet live */} + {selectedTournament.status?.toLowerCase() === 'upcoming' && ( +
+

+ Start Tournament +

+

Initialize agents and set tournament to live.

+ +
+ )} + + {/* Close Betting - only show if betting is open, not settled, and not cancelled */} + {!selectedTournament.betting_closed && !selectedTournament.winner_agent_id && selectedTournament.status?.toLowerCase() !== 'completed' && ( +
+

+ Close Betting +

+

Stop accepting new bets.

+ +
+ )} + + {/* Settle Tournament - only show after betting closed, not yet settled, and not cancelled */} + {selectedTournament.betting_closed && !selectedTournament.winner_agent_id && selectedTournament.status?.toLowerCase() !== 'completed' && ( +
+

+ Settle Tournament +

+

Select the winning agent:

+ + + + +
+ )} + + {/* Cancel Tournament - only show if not yet settled and not already cancelled */} + {!selectedTournament.winner_agent_id && selectedTournament.status?.toLowerCase() !== 'completed' && ( +
+

+ Cancel Tournament +

+

Cancel and allow bet refunds.

+ +
+ )} + + {/* Settled indicator */} + {selectedTournament.winner_agent_id && ( +
+ +

Tournament Settled

+

Winner has been declared and payouts are available.

+
+ )} + + {/* Cancelled indicator */} + {!selectedTournament.winner_agent_id && selectedTournament.status?.toLowerCase() === 'completed' && ( +
+ +

Tournament Cancelled

+

This tournament was cancelled. Bets are refundable.

+
+ )} + + )} +
+
+
+ + )} +
+
+ ); +} diff --git a/frontend/app/components/CenteredAgentCarousel.tsx b/frontend/app/components/CenteredAgentCarousel.tsx index 7805b78..b2e13fb 100644 --- a/frontend/app/components/CenteredAgentCarousel.tsx +++ b/frontend/app/components/CenteredAgentCarousel.tsx @@ -21,6 +21,10 @@ export default function CenteredAgentCarousel({ const frameRef = useRef(null); const startRef = useRef(0); + const lastProgressUpdate = useRef(0); + + // Use a ref for goNext to avoid re-triggering the RAF effect + const goNextRef = useRef<() => void>(() => {}); // ------------------------- // Navigation Handlers @@ -30,6 +34,7 @@ export default function CenteredAgentCarousel({ setActive((prev) => { const next = (prev + 1) % items.length; startRef.current = performance.now(); + lastProgressUpdate.current = 0; setProgress(0); return next; }); @@ -39,48 +44,55 @@ export default function CenteredAgentCarousel({ setActive((prev) => { const next = (prev - 1 + items.length) % items.length; startRef.current = performance.now(); + lastProgressUpdate.current = 0; setProgress(0); return next; }); }, [items.length]); + // Keep ref in sync + useEffect(() => { + goNextRef.current = goNext; + }, [goNext]); + // ------------------------- // Autorotation Animation // ------------------------- - const animate = useCallback( - (now: number) => { + useEffect(() => { + const animate = (now: number) => { const elapsed = now - startRef.current; const pct = Math.min(elapsed / autoRotateMs, 1); - setProgress(pct * 100); + // Throttle state updates to ~10fps to reduce re-renders + if (now - lastProgressUpdate.current > 100 || pct >= 1) { + lastProgressUpdate.current = now; + setProgress(pct * 100); + } if (pct < 1) { frameRef.current = requestAnimationFrame(animate); } else { - goNext(); + goNextRef.current(); } - }, - [autoRotateMs, goNext] - ); + }; - useEffect(() => { startRef.current = performance.now(); + lastProgressUpdate.current = 0; frameRef.current = requestAnimationFrame(animate); - // ✔ FIXED CLEANUP — always returns void return () => { if (frameRef.current !== null) { cancelAnimationFrame(frameRef.current); } }; - }, [active, animate]); + }, [active, autoRotateMs]); // ------------------------- // Swipe Gesture Navigation // ------------------------- - const onSwipeEnd = (_: any, info: PanInfo) => { + const onSwipeEnd = (_: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => { if (info.offset.x > 80 || info.velocity.x > 300) { goPrev(); } else if (info.offset.x < -80 || info.velocity.x < -300) { @@ -114,7 +126,7 @@ export default function CenteredAgentCarousel({ {/* ------------------------- TOP — CENTERED AGENT CARD ------------------------- */} -
+
= { + "/about": "About", + "/docs": "Documentation", + "/faq": "FAQ", + "/terms": "Terms of Service", +}; + +export function ConditionalLayout({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + + // Check if we're on an admin route + const isAdminRoute = pathname?.startsWith("/admin"); + + // Check if we're on the dashboard/home route + const isDashboardRoute = pathname === "/home"; + + // Check if we're on an informational page + const infoTitle = pathname ? INFO_PAGES[pathname] : undefined; + + if (isAdminRoute) { + // Admin routes: no navbar/footer (admin has its own sidebar) + return <>{children}; + } + + if (isDashboardRoute) { + // Dashboard route: use DashboardNavbar with section scroll + return ( + <> + +
{children}
+