From 2e9b2c83deac9d38ec7fcb3130d9c8915bac63c5 Mon Sep 17 00:00:00 2001 From: "William D. Jones" Date: Sun, 4 Jan 2026 17:13:32 -0600 Subject: [PATCH] Bet matching work done. --- backend/app/main.py | 5 +- backend/app/models/__init__.py | 10 + backend/app/models/admin_settings.py | 20 + backend/app/models/sport_event.py | 50 ++ backend/app/models/spread_bet.py | 47 ++ backend/app/models/user.py | 5 + backend/app/routers/admin.py | 138 ++++ backend/app/routers/sport_events.py | 123 ++++ backend/app/routers/spread_bets.py | 250 +++++++ backend/app/schemas/sport_event.py | 59 ++ backend/app/schemas/spread_bet.py | 45 ++ backend/data/app/__init__.py | 0 backend/data/app/blockchain/__init__.py | 20 + backend/data/app/blockchain/config.py | 189 ++++++ .../contracts/BetEscrow.pseudocode.md | 563 ++++++++++++++++ .../contracts/BetOracle.pseudocode.md | 617 ++++++++++++++++++ .../data/app/blockchain/contracts/README.md | 455 +++++++++++++ .../data/app/blockchain/services/__init__.py | 24 + .../blockchain/services/blockchain_indexer.py | 427 ++++++++++++ .../blockchain/services/blockchain_service.py | 466 +++++++++++++ .../blockchain/services/oracle_aggregator.py | 481 ++++++++++++++ .../app/blockchain/services/oracle_node.py | 471 +++++++++++++ backend/data/app/config.py | 14 + backend/data/app/crud/__init__.py | 0 backend/data/app/crud/bet.py | 79 +++ backend/data/app/crud/user.py | 56 ++ backend/data/app/crud/wallet.py | 54 ++ backend/data/app/database.py | 38 ++ backend/data/app/main.py | 50 ++ backend/data/app/models/__init__.py | 29 + backend/data/app/models/admin_settings.py | 32 + backend/data/app/models/bet.py | 98 +++ backend/data/app/models/sport_event.py | 67 ++ backend/data/app/models/spread_bet.py | 68 ++ backend/data/app/models/transaction.py | 42 ++ backend/data/app/models/user.py | 44 ++ backend/data/app/models/wallet.py | 21 + backend/data/app/routers/__init__.py | 0 backend/data/app/routers/admin.py | 241 +++++++ backend/data/app/routers/auth.py | 82 +++ backend/data/app/routers/bets.py | 173 +++++ backend/data/app/routers/sport_events.py | 158 +++++ backend/data/app/routers/spread_bets.py | 263 ++++++++ backend/data/app/routers/users.py | 62 ++ backend/data/app/routers/wallet.py | 50 ++ backend/data/app/routers/websocket.py | 43 ++ backend/data/app/schemas/__init__.py | 47 ++ backend/data/app/schemas/bet.py | 89 +++ backend/data/app/schemas/sport_event.py | 69 ++ backend/data/app/schemas/spread_bet.py | 60 ++ backend/data/app/schemas/user.py | 67 ++ backend/data/app/schemas/wallet.py | 38 ++ backend/data/app/services/__init__.py | 0 backend/data/app/services/auth_service.py | 50 ++ backend/data/app/services/bet_service.py | 178 +++++ backend/data/app/services/wallet_service.py | 52 ++ backend/data/app/utils/__init__.py | 0 backend/data/app/utils/exceptions.py | 50 ++ backend/data/app/utils/security.py | 38 ++ backend/init_spread_betting.py | 217 ++++++ 60 files changed, 7183 insertions(+), 1 deletion(-) create mode 100644 backend/app/models/admin_settings.py create mode 100644 backend/app/models/sport_event.py create mode 100644 backend/app/models/spread_bet.py create mode 100644 backend/app/routers/admin.py create mode 100644 backend/app/routers/sport_events.py create mode 100644 backend/app/routers/spread_bets.py create mode 100644 backend/app/schemas/sport_event.py create mode 100644 backend/app/schemas/spread_bet.py create mode 100644 backend/data/app/__init__.py create mode 100644 backend/data/app/blockchain/__init__.py create mode 100644 backend/data/app/blockchain/config.py create mode 100644 backend/data/app/blockchain/contracts/BetEscrow.pseudocode.md create mode 100644 backend/data/app/blockchain/contracts/BetOracle.pseudocode.md create mode 100644 backend/data/app/blockchain/contracts/README.md create mode 100644 backend/data/app/blockchain/services/__init__.py create mode 100644 backend/data/app/blockchain/services/blockchain_indexer.py create mode 100644 backend/data/app/blockchain/services/blockchain_service.py create mode 100644 backend/data/app/blockchain/services/oracle_aggregator.py create mode 100644 backend/data/app/blockchain/services/oracle_node.py create mode 100644 backend/data/app/config.py create mode 100644 backend/data/app/crud/__init__.py create mode 100644 backend/data/app/crud/bet.py create mode 100644 backend/data/app/crud/user.py create mode 100644 backend/data/app/crud/wallet.py create mode 100644 backend/data/app/database.py create mode 100644 backend/data/app/main.py create mode 100644 backend/data/app/models/__init__.py create mode 100644 backend/data/app/models/admin_settings.py create mode 100644 backend/data/app/models/bet.py create mode 100644 backend/data/app/models/sport_event.py create mode 100644 backend/data/app/models/spread_bet.py create mode 100644 backend/data/app/models/transaction.py create mode 100644 backend/data/app/models/user.py create mode 100644 backend/data/app/models/wallet.py create mode 100644 backend/data/app/routers/__init__.py create mode 100644 backend/data/app/routers/admin.py create mode 100644 backend/data/app/routers/auth.py create mode 100644 backend/data/app/routers/bets.py create mode 100644 backend/data/app/routers/sport_events.py create mode 100644 backend/data/app/routers/spread_bets.py create mode 100644 backend/data/app/routers/users.py create mode 100644 backend/data/app/routers/wallet.py create mode 100644 backend/data/app/routers/websocket.py create mode 100644 backend/data/app/schemas/__init__.py create mode 100644 backend/data/app/schemas/bet.py create mode 100644 backend/data/app/schemas/sport_event.py create mode 100644 backend/data/app/schemas/spread_bet.py create mode 100644 backend/data/app/schemas/user.py create mode 100644 backend/data/app/schemas/wallet.py create mode 100644 backend/data/app/services/__init__.py create mode 100644 backend/data/app/services/auth_service.py create mode 100644 backend/data/app/services/bet_service.py create mode 100644 backend/data/app/services/wallet_service.py create mode 100644 backend/data/app/utils/__init__.py create mode 100644 backend/data/app/utils/exceptions.py create mode 100644 backend/data/app/utils/security.py create mode 100644 backend/init_spread_betting.py diff --git a/backend/app/main.py b/backend/app/main.py index 49ed2a9..d8cb996 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,7 +2,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from contextlib import asynccontextmanager from app.database import init_db -from app.routers import auth, users, wallet, bets, websocket +from app.routers import auth, users, wallet, bets, websocket, admin, sport_events, spread_bets @asynccontextmanager @@ -35,6 +35,9 @@ app.include_router(users.router) app.include_router(wallet.router) app.include_router(bets.router) app.include_router(websocket.router) +app.include_router(admin.router) +app.include_router(sport_events.router) +app.include_router(spread_bets.router) @app.get("/") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 037e4dd..e3497e8 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -2,6 +2,9 @@ from app.models.user import User, UserStatus from app.models.wallet import Wallet from app.models.transaction import Transaction, TransactionType, TransactionStatus from app.models.bet import Bet, BetProposal, BetCategory, BetStatus, BetVisibility, ProposalStatus +from app.models.sport_event import SportEvent, SportType, EventStatus +from app.models.spread_bet import SpreadBet, SpreadBetStatus, TeamSide +from app.models.admin_settings import AdminSettings __all__ = [ "User", @@ -16,4 +19,11 @@ __all__ = [ "BetStatus", "BetVisibility", "ProposalStatus", + "SportEvent", + "SportType", + "EventStatus", + "SpreadBet", + "SpreadBetStatus", + "TeamSide", + "AdminSettings", ] diff --git a/backend/app/models/admin_settings.py b/backend/app/models/admin_settings.py new file mode 100644 index 0000000..86828aa --- /dev/null +++ b/backend/app/models/admin_settings.py @@ -0,0 +1,20 @@ +from sqlalchemy import String, Float, Numeric, DateTime +from sqlalchemy.orm import Mapped, mapped_column +from datetime import datetime +from decimal import Decimal +from app.database import Base + + +class AdminSettings(Base): + __tablename__ = "admin_settings" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + default_house_commission_percent: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=Decimal("10.00")) + default_min_bet_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), default=Decimal("10.00")) + default_max_bet_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), default=Decimal("1000.00")) + default_min_spread: Mapped[float] = mapped_column(Float, default=-10.0) + default_max_spread: Mapped[float] = mapped_column(Float, default=10.0) + spread_increment: Mapped[float] = mapped_column(Float, default=0.5) + platform_name: Mapped[str] = mapped_column(String(100), default="H2H Sports Betting") + maintenance_mode: Mapped[bool] = mapped_column(default=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) diff --git a/backend/app/models/sport_event.py b/backend/app/models/sport_event.py new file mode 100644 index 0000000..ddea8be --- /dev/null +++ b/backend/app/models/sport_event.py @@ -0,0 +1,50 @@ +from sqlalchemy import String, DateTime, Enum, Float, Integer, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship +from datetime import datetime +import enum +from app.database import Base + + +class SportType(enum.Enum): + FOOTBALL = "football" + BASKETBALL = "basketball" + BASEBALL = "baseball" + HOCKEY = "hockey" + SOCCER = "soccer" + + +class EventStatus(enum.Enum): + UPCOMING = "upcoming" + LIVE = "live" + COMPLETED = "completed" + CANCELLED = "cancelled" + + +class SportEvent(Base): + __tablename__ = "sport_events" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + sport: Mapped[SportType] = mapped_column(Enum(SportType)) + home_team: Mapped[str] = mapped_column(String(100)) + away_team: Mapped[str] = mapped_column(String(100)) + official_spread: Mapped[float] = mapped_column(Float) + game_time: Mapped[datetime] = mapped_column(DateTime) + venue: Mapped[str | None] = mapped_column(String(200), nullable=True) + league: Mapped[str | None] = mapped_column(String(100), nullable=True) + + # Spread betting config + min_spread: Mapped[float] = mapped_column(Float, default=-10.0) + max_spread: Mapped[float] = mapped_column(Float, default=10.0) + min_bet_amount: Mapped[float] = mapped_column(Float, default=10.0) + max_bet_amount: Mapped[float] = mapped_column(Float, default=1000.0) + + status: Mapped[EventStatus] = mapped_column(Enum(EventStatus), default=EventStatus.UPCOMING) + final_score_home: Mapped[int | None] = mapped_column(Integer, nullable=True) + final_score_away: Mapped[int | None] = mapped_column(Integer, nullable=True) + + created_by: Mapped[int] = mapped_column(ForeignKey("users.id")) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + spread_bets: Mapped[list["SpreadBet"]] = relationship(back_populates="event") diff --git a/backend/app/models/spread_bet.py b/backend/app/models/spread_bet.py new file mode 100644 index 0000000..aa9e6a0 --- /dev/null +++ b/backend/app/models/spread_bet.py @@ -0,0 +1,47 @@ +from sqlalchemy import String, DateTime, Enum, Float, Integer, ForeignKey, Numeric +from sqlalchemy.orm import Mapped, mapped_column, relationship +from datetime import datetime +from decimal import Decimal +import enum +from app.database import Base + + +class TeamSide(enum.Enum): + HOME = "home" + AWAY = "away" + + +class SpreadBetStatus(enum.Enum): + OPEN = "open" + MATCHED = "matched" + COMPLETED = "completed" + CANCELLED = "cancelled" + DISPUTED = "disputed" + + +class SpreadBet(Base): + __tablename__ = "spread_bets" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + event_id: Mapped[int] = mapped_column(ForeignKey("sport_events.id")) + spread: Mapped[float] = mapped_column(Float) + team: Mapped[TeamSide] = mapped_column(Enum(TeamSide)) + + creator_id: Mapped[int] = mapped_column(ForeignKey("users.id")) + taker_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) + + stake_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2)) + house_commission_percent: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=Decimal("10.00")) + + status: Mapped[SpreadBetStatus] = mapped_column(Enum(SpreadBetStatus), default=SpreadBetStatus.OPEN) + payout_amount: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True) + winner_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) + + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + matched_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + + # Relationships + event: Mapped["SportEvent"] = relationship(back_populates="spread_bets") + creator: Mapped["User"] = relationship(back_populates="created_spread_bets", foreign_keys=[creator_id]) + taker: Mapped["User"] = relationship(back_populates="taken_spread_bets", foreign_keys=[taker_id]) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index ced1bc1..e8e1b6f 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -30,6 +30,9 @@ class User(Base): losses: Mapped[int] = mapped_column(Integer, default=0) win_rate: Mapped[float] = mapped_column(Float, default=0.0) + # Admin flag + is_admin: Mapped[bool] = mapped_column(default=False) + status: Mapped[UserStatus] = mapped_column(Enum(UserStatus), default=UserStatus.ACTIVE) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) @@ -39,3 +42,5 @@ class User(Base): created_bets: Mapped[list["Bet"]] = relationship(back_populates="creator", foreign_keys="Bet.creator_id") accepted_bets: Mapped[list["Bet"]] = relationship(back_populates="opponent", foreign_keys="Bet.opponent_id") transactions: Mapped[list["Transaction"]] = relationship(back_populates="user") + created_spread_bets: Mapped[list["SpreadBet"]] = relationship(back_populates="creator", foreign_keys="SpreadBet.creator_id") + taken_spread_bets: Mapped[list["SpreadBet"]] = relationship(back_populates="taker", foreign_keys="SpreadBet.taker_id") diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py new file mode 100644 index 0000000..5736458 --- /dev/null +++ b/backend/app/routers/admin.py @@ -0,0 +1,138 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, update +from typing import List +from datetime import datetime +from decimal import Decimal + +from app.database import get_db +from app.models import User, SportEvent, SpreadBet, AdminSettings, EventStatus +from app.schemas.sport_event import SportEventCreate, SportEventUpdate, SportEvent as SportEventSchema +from app.routers.auth import get_current_user + +router = APIRouter(prefix="/api/v1/admin", tags=["admin"]) + + +# Dependency to check if user is admin +async def get_admin_user(current_user: User = Depends(get_current_user)) -> User: + if not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin access required" + ) + return current_user + + +@router.get("/settings") +async def get_admin_settings( + db: AsyncSession = Depends(get_db), + admin: User = Depends(get_admin_user) +): + result = await db.execute(select(AdminSettings).limit(1)) + settings = result.scalar_one_or_none() + if not settings: + settings = AdminSettings() + db.add(settings) + await db.commit() + await db.refresh(settings) + return settings + + +@router.patch("/settings") +async def update_admin_settings( + updates: dict, + db: AsyncSession = Depends(get_db), + admin: User = Depends(get_admin_user) +): + result = await db.execute(select(AdminSettings).limit(1)) + settings = result.scalar_one_or_none() + if not settings: + settings = AdminSettings() + db.add(settings) + + for key, value in updates.items(): + if hasattr(settings, key): + setattr(settings, key, value) + + await db.commit() + await db.refresh(settings) + return settings + + +@router.post("/events", response_model=SportEventSchema) +async def create_event( + event_data: SportEventCreate, + db: AsyncSession = Depends(get_db), + admin: User = Depends(get_admin_user) +): + event = SportEvent( + **event_data.model_dump(), + created_by=admin.id + ) + db.add(event) + await db.commit() + await db.refresh(event) + return event + + +@router.get("/events", response_model=List[SportEventSchema]) +async def list_events( + skip: int = 0, + limit: int = 50, + db: AsyncSession = Depends(get_db), + admin: User = Depends(get_admin_user) +): + result = await db.execute( + select(SportEvent).offset(skip).limit(limit).order_by(SportEvent.game_time.desc()) + ) + return result.scalars().all() + + +@router.patch("/events/{event_id}", response_model=SportEventSchema) +async def update_event( + event_id: int, + updates: SportEventUpdate, + db: AsyncSession = Depends(get_db), + admin: User = Depends(get_admin_user) +): + result = await db.execute(select(SportEvent).where(SportEvent.id == event_id)) + event = result.scalar_one_or_none() + if not event: + raise HTTPException(status_code=404, detail="Event not found") + + update_data = updates.model_dump(exclude_unset=True) + for key, value in update_data.items(): + setattr(event, key, value) + + await db.commit() + await db.refresh(event) + return event + + +@router.delete("/events/{event_id}") +async def delete_event( + event_id: int, + db: AsyncSession = Depends(get_db), + admin: User = Depends(get_admin_user) +): + result = await db.execute(select(SportEvent).where(SportEvent.id == event_id)) + event = result.scalar_one_or_none() + if not event: + raise HTTPException(status_code=404, detail="Event not found") + + # Check if there are any matched bets + bets_result = await db.execute( + select(SpreadBet).where( + SpreadBet.event_id == event_id, + SpreadBet.status != "open" + ) + ) + if bets_result.scalar_one_or_none(): + raise HTTPException( + status_code=400, + detail="Cannot delete event with matched bets" + ) + + await db.delete(event) + await db.commit() + return {"message": "Event deleted"} diff --git a/backend/app/routers/sport_events.py b/backend/app/routers/sport_events.py new file mode 100644 index 0000000..3a3e670 --- /dev/null +++ b/backend/app/routers/sport_events.py @@ -0,0 +1,123 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, and_ +from sqlalchemy.orm import selectinload +from typing import List +from datetime import datetime + +from app.database import get_db +from app.models import User, SportEvent, SpreadBet, AdminSettings, EventStatus, SpreadBetStatus, TeamSide +from app.schemas.sport_event import SportEvent as SportEventSchema, SportEventWithBets +from app.routers.auth import get_current_user + +router = APIRouter(prefix="/api/v1/sport-events", tags=["sport-events"]) + + +def generate_spread_grid(min_spread: float, max_spread: float, increment: float = 0.5) -> List[float]: + """Generate list of spread values from min to max with given increment""" + spreads = [] + current = min_spread + while current <= max_spread: + spreads.append(round(current, 1)) + current += increment + return spreads + + +@router.get("", response_model=List[SportEventSchema]) +async def list_upcoming_events( + skip: int = 0, + limit: int = 20, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + result = await db.execute( + select(SportEvent) + .where(SportEvent.status == EventStatus.UPCOMING) + .order_by(SportEvent.game_time.asc()) + .offset(skip) + .limit(limit) + ) + return result.scalars().all() + + +@router.get("/{event_id}") +async def get_event_with_grid( + event_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + # Get event + result = await db.execute( + select(SportEvent).where(SportEvent.id == event_id) + ) + event = result.scalar_one_or_none() + if not event: + raise HTTPException(status_code=404, detail="Event not found") + + # Get admin settings for spread increment + settings_result = await db.execute(select(AdminSettings).limit(1)) + settings = settings_result.scalar_one_or_none() + increment = settings.spread_increment if settings else 0.5 + + # Generate spread grid + spreads = generate_spread_grid(event.min_spread, event.max_spread, increment) + + # Get existing bets for this event + bets_result = await db.execute( + select(SpreadBet) + .options(selectinload(SpreadBet.creator)) + .where( + and_( + SpreadBet.event_id == event_id, + SpreadBet.status.in_([SpreadBetStatus.OPEN, SpreadBetStatus.MATCHED]) + ) + ) + ) + bets = bets_result.scalars().all() + + # Build spread grid with bet info + spread_grid = {} + for spread in spreads: + # Check for HOME team bet at this spread + bet_at_spread = next( + (b for b in bets if b.spread == spread and b.team == TeamSide.HOME), + None + ) + + if bet_at_spread: + spread_grid[str(spread)] = { + "bet_id": bet_at_spread.id, + "creator_id": bet_at_spread.creator_id, + "creator_username": bet_at_spread.creator.username, + "stake": float(bet_at_spread.stake_amount), + "status": bet_at_spread.status.value, + "team": bet_at_spread.team.value, + "can_take": ( + bet_at_spread.status == SpreadBetStatus.OPEN and + bet_at_spread.creator_id != current_user.id + ) + } + else: + spread_grid[str(spread)] = None + + return { + "id": event.id, + "sport": event.sport.value, + "home_team": event.home_team, + "away_team": event.away_team, + "official_spread": event.official_spread, + "game_time": event.game_time.isoformat(), + "venue": event.venue, + "league": event.league, + "min_spread": event.min_spread, + "max_spread": event.max_spread, + "min_bet_amount": event.min_bet_amount, + "max_bet_amount": event.max_bet_amount, + "status": event.status.value, + "final_score_home": event.final_score_home, + "final_score_away": event.final_score_away, + "created_by": event.created_by, + "created_at": event.created_at.isoformat(), + "updated_at": event.updated_at.isoformat(), + "spread_grid": spread_grid + } diff --git a/backend/app/routers/spread_bets.py b/backend/app/routers/spread_bets.py new file mode 100644 index 0000000..077abf0 --- /dev/null +++ b/backend/app/routers/spread_bets.py @@ -0,0 +1,250 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, and_ +from sqlalchemy.orm import selectinload +from typing import List +from datetime import datetime +from decimal import Decimal + +from app.database import get_db +from app.models import User, SportEvent, SpreadBet, Wallet, Transaction, AdminSettings +from app.models import EventStatus, SpreadBetStatus, TeamSide, TransactionType, TransactionStatus +from app.schemas.spread_bet import SpreadBet as SpreadBetSchema, SpreadBetCreate, SpreadBetDetail +from app.routers.auth import get_current_user + +router = APIRouter(prefix="/api/v1/spread-bets", tags=["spread-bets"]) + + +@router.post("", response_model=SpreadBetSchema) +async def create_spread_bet( + bet_data: SpreadBetCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + # Get the event + event_result = await db.execute( + select(SportEvent).where(SportEvent.id == bet_data.event_id) + ) + event = event_result.scalar_one_or_none() + if not event: + raise HTTPException(status_code=404, detail="Event not found") + + if event.status != EventStatus.UPCOMING: + raise HTTPException(status_code=400, detail="Event is not open for betting") + + # Validate stake amount + if bet_data.stake_amount < Decimal(str(event.min_bet_amount)): + raise HTTPException( + status_code=400, + detail=f"Minimum bet amount is ${event.min_bet_amount}" + ) + if bet_data.stake_amount > Decimal(str(event.max_bet_amount)): + raise HTTPException( + status_code=400, + detail=f"Maximum bet amount is ${event.max_bet_amount}" + ) + + # Check if spread is within range + if bet_data.spread < event.min_spread or bet_data.spread > event.max_spread: + raise HTTPException( + status_code=400, + detail=f"Spread must be between {event.min_spread} and {event.max_spread}" + ) + + # Check for existing bet at this spread + existing_result = await db.execute( + select(SpreadBet).where( + and_( + SpreadBet.event_id == bet_data.event_id, + SpreadBet.spread == bet_data.spread, + SpreadBet.team == bet_data.team, + SpreadBet.status == SpreadBetStatus.OPEN + ) + ) + ) + if existing_result.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="A bet already exists at this spread") + + # Get admin settings for commission + settings_result = await db.execute(select(AdminSettings).limit(1)) + settings = settings_result.scalar_one_or_none() + commission = settings.default_house_commission_percent if settings else Decimal("10.00") + + # Create the bet + bet = SpreadBet( + event_id=bet_data.event_id, + spread=bet_data.spread, + team=bet_data.team, + creator_id=current_user.id, + stake_amount=bet_data.stake_amount, + house_commission_percent=commission, + status=SpreadBetStatus.OPEN + ) + db.add(bet) + await db.commit() + await db.refresh(bet) + return bet + + +@router.post("/{bet_id}/take", response_model=SpreadBetSchema) +async def take_spread_bet( + bet_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + # Get the bet + bet_result = await db.execute( + select(SpreadBet) + .options(selectinload(SpreadBet.event)) + .where(SpreadBet.id == bet_id) + ) + bet = bet_result.scalar_one_or_none() + if not bet: + raise HTTPException(status_code=404, detail="Bet not found") + + if bet.status != SpreadBetStatus.OPEN: + raise HTTPException(status_code=400, detail="Bet is not open") + + if bet.creator_id == current_user.id: + raise HTTPException(status_code=400, detail="Cannot take your own bet") + + if bet.event.status != EventStatus.UPCOMING: + raise HTTPException(status_code=400, detail="Event is no longer open for betting") + + # Get wallets + creator_wallet_result = await db.execute( + select(Wallet).where(Wallet.user_id == bet.creator_id) + ) + creator_wallet = creator_wallet_result.scalar_one_or_none() + + taker_wallet_result = await db.execute( + select(Wallet).where(Wallet.user_id == current_user.id) + ) + taker_wallet = taker_wallet_result.scalar_one_or_none() + + if not taker_wallet: + raise HTTPException(status_code=400, detail="Wallet not found") + + # Check taker has sufficient balance + if taker_wallet.balance < bet.stake_amount: + raise HTTPException( + status_code=400, + detail=f"Insufficient balance. Need ${bet.stake_amount}" + ) + + # Check creator still has sufficient balance + if creator_wallet.balance < bet.stake_amount: + raise HTTPException( + status_code=400, + detail="Creator no longer has sufficient balance" + ) + + # Lock funds for both parties + creator_wallet.balance -= bet.stake_amount + creator_wallet.escrow += bet.stake_amount + taker_wallet.balance -= bet.stake_amount + taker_wallet.escrow += bet.stake_amount + + # Create transactions + creator_tx = Transaction( + user_id=bet.creator_id, + type=TransactionType.ESCROW_LOCK, + amount=-bet.stake_amount, + balance_after=creator_wallet.balance, + reference_id=bet.id, + description=f"Escrow lock for spread bet #{bet.id}", + status=TransactionStatus.COMPLETED + ) + taker_tx = Transaction( + user_id=current_user.id, + type=TransactionType.ESCROW_LOCK, + amount=-bet.stake_amount, + balance_after=taker_wallet.balance, + reference_id=bet.id, + description=f"Escrow lock for spread bet #{bet.id}", + status=TransactionStatus.COMPLETED + ) + db.add(creator_tx) + db.add(taker_tx) + + # Update bet + bet.taker_id = current_user.id + bet.status = SpreadBetStatus.MATCHED + bet.matched_at = datetime.utcnow() + + await db.commit() + await db.refresh(bet) + return bet + + +@router.get("/my-active", response_model=List[SpreadBetDetail]) +async def get_my_active_bets( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + result = await db.execute( + select(SpreadBet) + .options( + selectinload(SpreadBet.event), + selectinload(SpreadBet.creator), + selectinload(SpreadBet.taker) + ) + .where( + and_( + SpreadBet.status.in_([SpreadBetStatus.OPEN, SpreadBetStatus.MATCHED]), + (SpreadBet.creator_id == current_user.id) | (SpreadBet.taker_id == current_user.id) + ) + ) + .order_by(SpreadBet.created_at.desc()) + ) + bets = result.scalars().all() + + return [ + SpreadBetDetail( + id=bet.id, + event_id=bet.event_id, + spread=bet.spread, + team=bet.team, + creator_id=bet.creator_id, + taker_id=bet.taker_id, + stake_amount=bet.stake_amount, + house_commission_percent=bet.house_commission_percent, + status=bet.status, + payout_amount=bet.payout_amount, + winner_id=bet.winner_id, + created_at=bet.created_at, + matched_at=bet.matched_at, + completed_at=bet.completed_at, + creator_username=bet.creator.username, + taker_username=bet.taker.username if bet.taker else None, + event_home_team=bet.event.home_team, + event_away_team=bet.event.away_team, + event_official_spread=bet.event.official_spread, + event_game_time=bet.event.game_time + ) + for bet in bets + ] + + +@router.delete("/{bet_id}") +async def cancel_spread_bet( + bet_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + bet_result = await db.execute( + select(SpreadBet).where(SpreadBet.id == bet_id) + ) + bet = bet_result.scalar_one_or_none() + if not bet: + raise HTTPException(status_code=404, detail="Bet not found") + + if bet.creator_id != current_user.id: + raise HTTPException(status_code=403, detail="Only the creator can cancel this bet") + + if bet.status != SpreadBetStatus.OPEN: + raise HTTPException(status_code=400, detail="Can only cancel open bets") + + bet.status = SpreadBetStatus.CANCELLED + await db.commit() + return {"message": "Bet cancelled"} diff --git a/backend/app/schemas/sport_event.py b/backend/app/schemas/sport_event.py new file mode 100644 index 0000000..873d9fb --- /dev/null +++ b/backend/app/schemas/sport_event.py @@ -0,0 +1,59 @@ +from pydantic import BaseModel +from datetime import datetime +from typing import Optional, Dict, Any +from app.models.sport_event import SportType, EventStatus + + +class SportEventCreate(BaseModel): + sport: SportType + home_team: str + away_team: str + official_spread: float + game_time: datetime + venue: Optional[str] = None + league: Optional[str] = None + min_spread: float = -10.0 + max_spread: float = 10.0 + min_bet_amount: float = 10.0 + max_bet_amount: float = 1000.0 + + +class SportEventUpdate(BaseModel): + sport: Optional[SportType] = None + home_team: Optional[str] = None + away_team: Optional[str] = None + official_spread: Optional[float] = None + game_time: Optional[datetime] = None + venue: Optional[str] = None + league: Optional[str] = None + status: Optional[EventStatus] = None + final_score_home: Optional[int] = None + final_score_away: Optional[int] = None + + +class SportEvent(BaseModel): + id: int + sport: SportType + home_team: str + away_team: str + official_spread: float + game_time: datetime + venue: Optional[str] = None + league: Optional[str] = None + min_spread: float + max_spread: float + min_bet_amount: float + max_bet_amount: float + status: EventStatus + final_score_home: Optional[int] = None + final_score_away: Optional[int] = None + created_by: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class SportEventWithBets(SportEvent): + spread_grid: Dict[str, Any] diff --git a/backend/app/schemas/spread_bet.py b/backend/app/schemas/spread_bet.py new file mode 100644 index 0000000..4c07685 --- /dev/null +++ b/backend/app/schemas/spread_bet.py @@ -0,0 +1,45 @@ +from pydantic import BaseModel +from datetime import datetime +from decimal import Decimal +from typing import Optional +from app.models.spread_bet import TeamSide, SpreadBetStatus + + +class SpreadBetCreate(BaseModel): + event_id: int + spread: float + team: TeamSide + stake_amount: Decimal + + +class SpreadBetTake(BaseModel): + pass + + +class SpreadBet(BaseModel): + id: int + event_id: int + spread: float + team: TeamSide + creator_id: int + taker_id: Optional[int] = None + stake_amount: Decimal + house_commission_percent: Decimal + status: SpreadBetStatus + payout_amount: Optional[Decimal] = None + winner_id: Optional[int] = None + created_at: datetime + matched_at: Optional[datetime] = None + completed_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +class SpreadBetDetail(SpreadBet): + creator_username: str + taker_username: Optional[str] = None + event_home_team: str + event_away_team: str + event_official_spread: float + event_game_time: datetime diff --git a/backend/data/app/__init__.py b/backend/data/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/data/app/blockchain/__init__.py b/backend/data/app/blockchain/__init__.py new file mode 100644 index 0000000..f0269c2 --- /dev/null +++ b/backend/data/app/blockchain/__init__.py @@ -0,0 +1,20 @@ +""" +Blockchain Integration Package + +This package provides smart contract integration for the H2H betting platform, +implementing a hybrid architecture where escrow and settlement occur on-chain +while maintaining fast queries through database caching. + +Main components: +- contracts/: Smart contract pseudocode and architecture +- services/: Web3 integration, event indexing, and oracle network +- config.py: Blockchain configuration +""" + +from .config import get_blockchain_config, CHAIN_IDS, BLOCK_EXPLORERS + +__all__ = [ + "get_blockchain_config", + "CHAIN_IDS", + "BLOCK_EXPLORERS", +] diff --git a/backend/data/app/blockchain/config.py b/backend/data/app/blockchain/config.py new file mode 100644 index 0000000..0b3a705 --- /dev/null +++ b/backend/data/app/blockchain/config.py @@ -0,0 +1,189 @@ +""" +Blockchain Configuration + +Centralized configuration for blockchain integration including: +- RPC endpoints +- Contract addresses +- Oracle node addresses +- Network settings + +In production, these would be loaded from environment variables. +""" + +from typing import Dict, Any +import os + + +# Network Configuration +NETWORK = os.getenv("BLOCKCHAIN_NETWORK", "sepolia") # sepolia, polygon-mumbai, mainnet, polygon + +# RPC Endpoints +RPC_URLS = { + "mainnet": os.getenv("ETHEREUM_MAINNET_RPC", "https://eth-mainnet.alchemyapi.io/v2/YOUR-API-KEY"), + "sepolia": os.getenv("ETHEREUM_SEPOLIA_RPC", "https://eth-sepolia.alchemyapi.io/v2/YOUR-API-KEY"), + "polygon": os.getenv("POLYGON_MAINNET_RPC", "https://polygon-mainnet.g.alchemy.com/v2/YOUR-API-KEY"), + "polygon-mumbai": os.getenv("POLYGON_MUMBAI_RPC", "https://polygon-mumbai.g.alchemy.com/v2/YOUR-API-KEY"), +} + +# Contract Addresses (per network) +CONTRACT_ADDRESSES = { + "mainnet": { + "bet_escrow": os.getenv("MAINNET_BET_ESCROW_ADDRESS", "0x0000000000000000000000000000000000000000"), + "bet_oracle": os.getenv("MAINNET_BET_ORACLE_ADDRESS", "0x0000000000000000000000000000000000000000"), + }, + "sepolia": { + "bet_escrow": os.getenv("SEPOLIA_BET_ESCROW_ADDRESS", "0x1234567890abcdef1234567890abcdef12345678"), + "bet_oracle": os.getenv("SEPOLIA_BET_ORACLE_ADDRESS", "0xfedcba0987654321fedcba0987654321fedcba09"), + }, + "polygon": { + "bet_escrow": os.getenv("POLYGON_BET_ESCROW_ADDRESS", "0x0000000000000000000000000000000000000000"), + "bet_oracle": os.getenv("POLYGON_BET_ORACLE_ADDRESS", "0x0000000000000000000000000000000000000000"), + }, + "polygon-mumbai": { + "bet_escrow": os.getenv("MUMBAI_BET_ESCROW_ADDRESS", "0xabcdef1234567890abcdef1234567890abcdef12"), + "bet_oracle": os.getenv("MUMBAI_BET_ORACLE_ADDRESS", "0x234567890abcdef1234567890abcdef123456789"), + }, +} + +# Backend Signer Configuration +# IMPORTANT: In production, use a secure key management system (AWS KMS, HashiCorp Vault, etc.) +# Never commit private keys to version control +BACKEND_PRIVATE_KEY = os.getenv("BLOCKCHAIN_BACKEND_PRIVATE_KEY", "") + +# Oracle Node Configuration +ORACLE_NODES = { + "node1": { + "address": os.getenv("ORACLE_NODE1_ADDRESS", "0xNode1Address..."), + "endpoint": os.getenv("ORACLE_NODE1_ENDPOINT", "https://oracle1.h2h.com"), + }, + "node2": { + "address": os.getenv("ORACLE_NODE2_ADDRESS", "0xNode2Address..."), + "endpoint": os.getenv("ORACLE_NODE2_ENDPOINT", "https://oracle2.h2h.com"), + }, + "node3": { + "address": os.getenv("ORACLE_NODE3_ADDRESS", "0xNode3Address..."), + "endpoint": os.getenv("ORACLE_NODE3_ENDPOINT", "https://oracle3.h2h.com"), + }, + "node4": { + "address": os.getenv("ORACLE_NODE4_ADDRESS", "0xNode4Address..."), + "endpoint": os.getenv("ORACLE_NODE4_ENDPOINT", "https://oracle4.h2h.com"), + }, + "node5": { + "address": os.getenv("ORACLE_NODE5_ADDRESS", "0xNode5Address..."), + "endpoint": os.getenv("ORACLE_NODE5_ENDPOINT", "https://oracle5.h2h.com"), + }, +} + +# Oracle Consensus Configuration +ORACLE_CONSENSUS_THRESHOLD = int(os.getenv("ORACLE_CONSENSUS_THRESHOLD", "3")) # 3 of 5 nodes must agree +ORACLE_TIMEOUT_SECONDS = int(os.getenv("ORACLE_TIMEOUT_SECONDS", "3600")) # 1 hour + +# Indexer Configuration +INDEXER_POLL_INTERVAL = int(os.getenv("INDEXER_POLL_INTERVAL", "10")) # seconds +INDEXER_START_BLOCK = int(os.getenv("INDEXER_START_BLOCK", "0")) # 0 = contract deployment block + +# Gas Configuration +GAS_PRICE_MULTIPLIER = float(os.getenv("GAS_PRICE_MULTIPLIER", "1.2")) # 20% above current gas price +MAX_GAS_PRICE_GWEI = int(os.getenv("MAX_GAS_PRICE_GWEI", "500")) # Never pay more than 500 gwei + +# Transaction Configuration +TRANSACTION_TIMEOUT = int(os.getenv("TRANSACTION_TIMEOUT", "300")) # 5 minutes +CONFIRMATION_BLOCKS = int(os.getenv("CONFIRMATION_BLOCKS", "2")) # Wait for 2 block confirmations + +# API Endpoints for Oracle Data Sources +API_ENDPOINTS = { + "espn": os.getenv("ESPN_API_KEY", "https://api.espn.com/v1"), + "odds_api": os.getenv("ODDS_API_KEY", "https://api.the-odds-api.com/v4"), + "oscars": os.getenv("OSCARS_API_KEY", "https://api.oscars.com"), +} + + +def get_blockchain_config(network: str = None) -> Dict[str, Any]: + """ + Get blockchain configuration for specified network. + + Args: + network: Network name (mainnet, sepolia, polygon, polygon-mumbai) + If None, uses NETWORK from environment + + Returns: + Dict with all blockchain configuration + """ + if network is None: + network = NETWORK + + if network not in RPC_URLS: + raise ValueError(f"Unknown network: {network}") + + return { + "network": network, + "rpc_url": RPC_URLS[network], + "bet_escrow_address": CONTRACT_ADDRESSES[network]["bet_escrow"], + "bet_oracle_address": CONTRACT_ADDRESSES[network]["bet_oracle"], + "backend_private_key": BACKEND_PRIVATE_KEY, + "oracle_nodes": ORACLE_NODES, + "oracle_consensus_threshold": ORACLE_CONSENSUS_THRESHOLD, + "oracle_timeout": ORACLE_TIMEOUT_SECONDS, + "indexer_poll_interval": INDEXER_POLL_INTERVAL, + "indexer_start_block": INDEXER_START_BLOCK, + "gas_price_multiplier": GAS_PRICE_MULTIPLIER, + "max_gas_price_gwei": MAX_GAS_PRICE_GWEI, + "transaction_timeout": TRANSACTION_TIMEOUT, + "confirmation_blocks": CONFIRMATION_BLOCKS, + "api_endpoints": API_ENDPOINTS, + } + + +# Network Chain IDs (for frontend) +CHAIN_IDS = { + "mainnet": 1, + "sepolia": 11155111, + "polygon": 137, + "polygon-mumbai": 80001, +} + + +# Block Explorer URLs (for frontend links) +BLOCK_EXPLORERS = { + "mainnet": "https://etherscan.io", + "sepolia": "https://sepolia.etherscan.io", + "polygon": "https://polygonscan.com", + "polygon-mumbai": "https://mumbai.polygonscan.com", +} + + +# Example .env file content: +""" +# Blockchain Configuration +BLOCKCHAIN_NETWORK=sepolia + +# RPC Endpoints (get API keys from Alchemy, Infura, or QuickNode) +ETHEREUM_MAINNET_RPC=https://eth-mainnet.alchemyapi.io/v2/YOUR-API-KEY +ETHEREUM_SEPOLIA_RPC=https://eth-sepolia.alchemyapi.io/v2/YOUR-API-KEY +POLYGON_MAINNET_RPC=https://polygon-mainnet.g.alchemy.com/v2/YOUR-API-KEY +POLYGON_MUMBAI_RPC=https://polygon-mumbai.g.alchemy.com/v2/YOUR-API-KEY + +# Contract Addresses (update after deployment) +SEPOLIA_BET_ESCROW_ADDRESS=0x1234567890abcdef1234567890abcdef12345678 +SEPOLIA_BET_ORACLE_ADDRESS=0xfedcba0987654321fedcba0987654321fedcba09 + +# Backend Signer (NEVER commit to git!) +BLOCKCHAIN_BACKEND_PRIVATE_KEY=0xYOUR_PRIVATE_KEY_HERE + +# Oracle Nodes +ORACLE_NODE1_ADDRESS=0xNode1Address... +ORACLE_NODE1_ENDPOINT=https://oracle1.h2h.com +ORACLE_CONSENSUS_THRESHOLD=3 + +# Indexer +INDEXER_START_BLOCK=12345678 +INDEXER_POLL_INTERVAL=10 + +# Gas +GAS_PRICE_MULTIPLIER=1.2 +MAX_GAS_PRICE_GWEI=500 + +# External APIs +ESPN_API_KEY=YOUR_ESPN_API_KEY +ODDS_API_KEY=YOUR_ODDS_API_KEY +""" diff --git a/backend/data/app/blockchain/contracts/BetEscrow.pseudocode.md b/backend/data/app/blockchain/contracts/BetEscrow.pseudocode.md new file mode 100644 index 0000000..8682101 --- /dev/null +++ b/backend/data/app/blockchain/contracts/BetEscrow.pseudocode.md @@ -0,0 +1,563 @@ +# BetEscrow Smart Contract (Pseudocode) + +## Overview + +The BetEscrow contract manages the entire lifecycle of peer-to-peer bets on the blockchain. It handles bet creation, escrow locking, settlement, and fund distribution in a trustless manner. + +## State Variables + +```solidity +// Contract state +mapping(uint256 => Bet) public bets; +mapping(address => uint256[]) public userBetIds; +uint256 public nextBetId; +address public oracleContract; +address public owner; + +// Escrow tracking +mapping(uint256 => uint256) public escrowBalance; // betId => total locked amount +``` + +## Data Structures + +```solidity +enum BetStatus { + OPEN, // Created, waiting for opponent + MATCHED, // Opponent accepted, funds locked + PENDING_ORACLE, // Waiting for oracle settlement + COMPLETED, // Settled and paid out + CANCELLED, // Cancelled before matching + DISPUTED // Settlement disputed +} + +struct Bet { + uint256 betId; + address creator; + address opponent; + uint256 stakeAmount; // Amount each party stakes (in wei) + BetStatus status; + uint256 creatorOdds; // Multiplier for creator (scaled by 100, e.g., 150 = 1.5x) + uint256 opponentOdds; // Multiplier for opponent + uint256 createdAt; // Block timestamp + uint256 eventTimestamp; // When the real-world event occurs + bytes32 eventId; // External event identifier for oracle + address winner; // Winner address (null until settled) + uint256 settledAt; // Settlement timestamp +} +``` + +## Events + +```solidity +event BetCreated( + uint256 indexed betId, + address indexed creator, + uint256 stakeAmount, + bytes32 eventId, + uint256 eventTimestamp +); + +event BetMatched( + uint256 indexed betId, + address indexed opponent, + uint256 totalEscrow +); + +event BetSettled( + uint256 indexed betId, + address indexed winner, + uint256 payoutAmount +); + +event BetCancelled( + uint256 indexed betId, + address indexed creator +); + +event BetDisputed( + uint256 indexed betId, + address indexed disputedBy, + uint256 timestamp +); + +event EscrowLocked( + uint256 indexed betId, + address indexed user, + uint256 amount +); + +event EscrowReleased( + uint256 indexed betId, + address indexed user, + uint256 amount +); +``` + +## Modifiers + +```solidity +modifier onlyOracle() { + require(msg.sender == oracleContract, "Only oracle can call this"); + _; +} + +modifier onlyOwner() { + require(msg.sender == owner, "Only owner can call this"); + _; +} + +modifier betExists(uint256 betId) { + require(betId < nextBetId, "Bet does not exist"); + _; +} + +modifier onlyParticipant(uint256 betId) { + Bet storage bet = bets[betId]; + require( + msg.sender == bet.creator || msg.sender == bet.opponent, + "Not a participant" + ); + _; +} +``` + +## Core Functions + +### 1. Create Bet + +```solidity +function createBet( + uint256 stakeAmount, + uint256 creatorOdds, + uint256 opponentOdds, + uint256 eventTimestamp, + bytes32 eventId +) external returns (uint256) { + // Validation + require(stakeAmount > 0, "Stake must be positive"); + require(stakeAmount <= 10000 ether, "Stake exceeds maximum"); + require(creatorOdds > 0, "Creator odds must be positive"); + require(opponentOdds > 0, "Opponent odds must be positive"); + require(eventTimestamp > block.timestamp, "Event must be in future"); + + // Generate bet ID + uint256 betId = nextBetId++; + + // Create bet (NO funds locked yet) + bets[betId] = Bet({ + betId: betId, + creator: msg.sender, + opponent: address(0), + stakeAmount: stakeAmount, + status: BetStatus.OPEN, + creatorOdds: creatorOdds, + opponentOdds: opponentOdds, + createdAt: block.timestamp, + eventTimestamp: eventTimestamp, + eventId: eventId, + winner: address(0), + settledAt: 0 + }); + + // Track user's bets + userBetIds[msg.sender].push(betId); + + // Emit event + emit BetCreated(betId, msg.sender, stakeAmount, eventId, eventTimestamp); + + return betId; +} +``` + +### 2. Accept Bet (Lock Escrow for Both Parties) + +```solidity +function acceptBet(uint256 betId) + external + payable + betExists(betId) +{ + Bet storage bet = bets[betId]; + + // Validation + require(bet.status == BetStatus.OPEN, "Bet not open"); + require(msg.sender != bet.creator, "Cannot accept own bet"); + require(msg.value == bet.stakeAmount, "Incorrect stake amount"); + + // CRITICAL: Lock creator's funds from their account + // NOTE: In production, creator would approve this contract to spend their funds + // For this pseudocode, assume creator has pre-approved the transfer + require( + transferFrom(bet.creator, address(this), bet.stakeAmount), + "Creator funds transfer failed" + ); + + // Lock opponent's funds (already sent with msg.value) + // Both stakes now held in contract + + // Update escrow balance + escrowBalance[betId] = bet.stakeAmount * 2; + + // Update bet state + bet.opponent = msg.sender; + bet.status = BetStatus.MATCHED; + + // Track opponent's bets + userBetIds[msg.sender].push(betId); + + // Emit events + emit EscrowLocked(betId, bet.creator, bet.stakeAmount); + emit EscrowLocked(betId, msg.sender, bet.stakeAmount); + emit BetMatched(betId, msg.sender, escrowBalance[betId]); +} +``` + +### 3. Request Oracle Settlement + +```solidity +function requestSettlement(uint256 betId) + external + betExists(betId) + onlyParticipant(betId) +{ + Bet storage bet = bets[betId]; + + // Validation + require( + bet.status == BetStatus.MATCHED, + "Bet must be matched" + ); + require( + block.timestamp >= bet.eventTimestamp, + "Event has not occurred yet" + ); + + // Update status + bet.status = BetStatus.PENDING_ORACLE; + + // Call oracle contract to request settlement + IOracleContract(oracleContract).requestSettlement( + betId, + bet.eventId + ); +} +``` + +### 4. Settle Bet (Called by Oracle) + +```solidity +function settleBet(uint256 betId, address winnerId) + external + betExists(betId) + onlyOracle +{ + Bet storage bet = bets[betId]; + + // Validation + require( + bet.status == BetStatus.MATCHED || + bet.status == BetStatus.PENDING_ORACLE, + "Invalid bet status for settlement" + ); + require( + winnerId == bet.creator || winnerId == bet.opponent, + "Winner must be a participant" + ); + + // Calculate payout + uint256 totalPayout = escrowBalance[betId]; // Both stakes + + // Transfer funds to winner + require( + payable(winnerId).transfer(totalPayout), + "Payout transfer failed" + ); + + // Update bet state + bet.winner = winnerId; + bet.status = BetStatus.COMPLETED; + bet.settledAt = block.timestamp; + + // Clear escrow + escrowBalance[betId] = 0; + + // Emit events + emit EscrowReleased(betId, winnerId, totalPayout); + emit BetSettled(betId, winnerId, totalPayout); +} +``` + +### 5. Dispute Settlement + +```solidity +function disputeBet(uint256 betId) + external + betExists(betId) + onlyParticipant(betId) +{ + Bet storage bet = bets[betId]; + + // Validation + require( + bet.status == BetStatus.PENDING_ORACLE || + bet.status == BetStatus.COMPLETED, + "Can only dispute pending or completed bets" + ); + + // If completed, must dispute within 48 hours + if (bet.status == BetStatus.COMPLETED) { + require( + block.timestamp <= bet.settledAt + 48 hours, + "Dispute window expired" + ); + } + + // Mark as disputed + bet.status = BetStatus.DISPUTED; + + emit BetDisputed(betId, msg.sender, block.timestamp); +} +``` + +### 6. Cancel Bet (Before Matching) + +```solidity +function cancelBet(uint256 betId) + external + betExists(betId) +{ + Bet storage bet = bets[betId]; + + // Validation + require(msg.sender == bet.creator, "Only creator can cancel"); + require(bet.status == BetStatus.OPEN, "Can only cancel open bets"); + + // Mark as cancelled (no funds to refund since none were locked) + bet.status = BetStatus.CANCELLED; + + emit BetCancelled(betId, msg.sender); +} +``` + +### 7. Admin Settlement (For Disputed Bets) + +```solidity +function adminSettleBet(uint256 betId, address winnerId) + external + betExists(betId) + onlyOwner +{ + Bet storage bet = bets[betId]; + + // Validation + require(bet.status == BetStatus.DISPUTED, "Only for disputed bets"); + require( + winnerId == bet.creator || winnerId == bet.opponent, + "Winner must be a participant" + ); + + // Calculate payout + uint256 totalPayout = escrowBalance[betId]; + + // Transfer funds to winner + require( + payable(winnerId).transfer(totalPayout), + "Payout transfer failed" + ); + + // Update bet state + bet.winner = winnerId; + bet.status = BetStatus.COMPLETED; + bet.settledAt = block.timestamp; + + // Clear escrow + escrowBalance[betId] = 0; + + // Emit events + emit EscrowReleased(betId, winnerId, totalPayout); + emit BetSettled(betId, winnerId, totalPayout); +} +``` + +### 8. Manual Settlement (Fallback if Oracle Fails) + +```solidity +function manualSettleAfterTimeout(uint256 betId, address winnerId) + external + betExists(betId) + onlyParticipant(betId) +{ + Bet storage bet = bets[betId]; + + // Validation + require( + bet.status == BetStatus.PENDING_ORACLE, + "Must be waiting for oracle" + ); + require( + block.timestamp >= bet.eventTimestamp + 24 hours, + "Oracle timeout not reached" + ); + require( + winnerId == bet.creator || winnerId == bet.opponent, + "Winner must be a participant" + ); + + // Settle manually (without oracle) + // Note: Other participant can dispute within 48 hours + + uint256 totalPayout = escrowBalance[betId]; + + require( + payable(winnerId).transfer(totalPayout), + "Payout transfer failed" + ); + + bet.winner = winnerId; + bet.status = BetStatus.COMPLETED; + bet.settledAt = block.timestamp; + + escrowBalance[betId] = 0; + + emit EscrowReleased(betId, winnerId, totalPayout); + emit BetSettled(betId, winnerId, totalPayout); +} +``` + +## View Functions + +```solidity +function getBet(uint256 betId) + external + view + betExists(betId) + returns (Bet memory) +{ + return bets[betId]; +} + +function getUserBets(address user) + external + view + returns (uint256[] memory) +{ + return userBetIds[user]; +} + +function getUserEscrow(address user) + external + view + returns (uint256 totalEscrow) +{ + uint256[] memory betIds = userBetIds[user]; + + for (uint256 i = 0; i < betIds.length; i++) { + Bet memory bet = bets[betIds[i]]; + + // Only count matched bets where user is a participant + if (bet.status == BetStatus.MATCHED || bet.status == BetStatus.PENDING_ORACLE) { + if (bet.creator == user || bet.opponent == user) { + totalEscrow += bet.stakeAmount; + } + } + } + + return totalEscrow; +} + +function getBetsByStatus(BetStatus status) + external + view + returns (uint256[] memory) +{ + // Count matching bets + uint256 count = 0; + for (uint256 i = 0; i < nextBetId; i++) { + if (bets[i].status == status) { + count++; + } + } + + // Populate array + uint256[] memory matchingBets = new uint256[](count); + uint256 index = 0; + for (uint256 i = 0; i < nextBetId; i++) { + if (bets[i].status == status) { + matchingBets[index] = i; + index++; + } + } + + return matchingBets; +} +``` + +## State Machine Diagram + +``` + createBet() + ↓ + [OPEN] + ↓ + ┌──────┴──────┐ + │ │ +cancelBet() acceptBet() + │ │ + ↓ ↓ +[CANCELLED] [MATCHED] + ↓ + requestSettlement() + ↓ + [PENDING_ORACLE] + ↓ + ┌────────┴────────┐ + │ │ + settleBet() disputeBet() + │ │ + ↓ ↓ + [COMPLETED] [DISPUTED] + │ + adminSettleBet() + │ + ↓ + [COMPLETED] +``` + +## Security Features + +1. **Reentrancy Protection**: Use checks-effects-interactions pattern +2. **Atomic Escrow**: Both parties' funds locked in single transaction +3. **Role-Based Access**: Only oracle can settle, only owner can admin-settle +4. **Dispute Window**: 48-hour window to dispute settlement +5. **Timeout Fallback**: Manual settlement if oracle fails after 24 hours +6. **Event Logging**: All state changes emit events for transparency + +## Gas Optimization Notes + +- Use `storage` pointers instead of loading full structs +- Pack struct fields to minimize storage slots +- Batch operations where possible +- Consider using `uint128` for stake amounts if max is known +- Use events instead of storing historical data on-chain + +## Integration with Oracle + +The BetEscrow contract delegates settlement authority to the Oracle contract. The oracle: +1. Listens for `requestSettlement()` calls +2. Fetches external API data +3. Determines winner via multi-node consensus +4. Calls `settleBet()` with the winner address + +## Deployment Configuration + +```solidity +constructor(address _oracleContract) { + owner = msg.sender; + oracleContract = _oracleContract; + nextBetId = 0; +} + +function setOracleContract(address _newOracle) external onlyOwner { + oracleContract = _newOracle; +} +``` diff --git a/backend/data/app/blockchain/contracts/BetOracle.pseudocode.md b/backend/data/app/blockchain/contracts/BetOracle.pseudocode.md new file mode 100644 index 0000000..fa96c02 --- /dev/null +++ b/backend/data/app/blockchain/contracts/BetOracle.pseudocode.md @@ -0,0 +1,617 @@ +# BetOracle Smart Contract (Pseudocode) + +## Overview + +The BetOracle contract acts as a bridge between the blockchain and external data sources. It implements a decentralized oracle network where multiple independent nodes fetch data from APIs, reach consensus, and automatically settle bets based on real-world event outcomes. + +## State Variables + +```solidity +// Contract references +address public betEscrowContract; +address public owner; + +// Oracle network +address[] public trustedNodes; +mapping(address => bool) public isNodeTrusted; +uint256 public consensusThreshold; // e.g., 3 of 5 nodes must agree + +// Oracle requests +mapping(uint256 => OracleRequest) public requests; +uint256 public nextRequestId; + +// Node submissions +mapping(uint256 => NodeSubmission[]) public submissions; // requestId => submissions +mapping(uint256 => mapping(address => bool)) public hasSubmitted; // requestId => node => submitted +``` + +## Data Structures + +```solidity +enum RequestStatus { + PENDING, // Request created, waiting for nodes + FULFILLED, // Consensus reached, bet settled + DISPUTED, // No consensus or disputed result + TIMED_OUT // Oracle failed to respond in time +} + +struct OracleRequest { + uint256 requestId; + uint256 betId; + bytes32 eventId; // External event identifier + string apiEndpoint; // URL to fetch result from + uint256 requestedAt; // Block timestamp + RequestStatus status; + address consensusWinner; // Agreed-upon winner + uint256 fulfilledAt; // Settlement timestamp +} + +struct NodeSubmission { + address nodeAddress; + address proposedWinner; // Who the node thinks won + bytes resultData; // Raw API response data + bytes signature; // Node's signature of the result + uint256 submittedAt; // Block timestamp +} + +struct ApiAdapter { + string apiEndpoint; + string resultPath; // JSON path to extract winner (e.g., "data.winner") + mapping(string => address) resultMapping; // Map API result to bet participant +} +``` + +## Events + +```solidity +event OracleRequested( + uint256 indexed requestId, + uint256 indexed betId, + bytes32 eventId, + string apiEndpoint +); + +event NodeSubmissionReceived( + uint256 indexed requestId, + address indexed node, + address proposedWinner +); + +event ConsensusReached( + uint256 indexed requestId, + address indexed winner, + uint256 nodeCount +); + +event OracleFulfilled( + uint256 indexed requestId, + uint256 indexed betId, + address winner +); + +event OracleDisputed( + uint256 indexed requestId, + string reason +); + +event TrustedNodeAdded(address indexed node); +event TrustedNodeRemoved(address indexed node); +``` + +## Modifiers + +```solidity +modifier onlyBetEscrow() { + require(msg.sender == betEscrowContract, "Only BetEscrow can call"); + _; +} + +modifier onlyTrustedNode() { + require(isNodeTrusted[msg.sender], "Not a trusted oracle node"); + _; +} + +modifier onlyOwner() { + require(msg.sender == owner, "Only owner can call"); + _; +} + +modifier requestExists(uint256 requestId) { + require(requestId < nextRequestId, "Request does not exist"); + _; +} +``` + +## Core Functions + +### 1. Request Settlement (Called by BetEscrow) + +```solidity +function requestSettlement( + uint256 betId, + bytes32 eventId +) external onlyBetEscrow returns (uint256) { + // Fetch bet details from BetEscrow contract + IBetEscrow.Bet memory bet = IBetEscrow(betEscrowContract).getBet(betId); + + // Determine API endpoint based on event type + string memory apiEndpoint = getApiEndpointForEvent(eventId); + + // Create oracle request + uint256 requestId = nextRequestId++; + + requests[requestId] = OracleRequest({ + requestId: requestId, + betId: betId, + eventId: eventId, + apiEndpoint: apiEndpoint, + requestedAt: block.timestamp, + status: RequestStatus.PENDING, + consensusWinner: address(0), + fulfilledAt: 0 + }); + + // Emit event for off-chain oracle nodes to listen + emit OracleRequested(requestId, betId, eventId, apiEndpoint); + + return requestId; +} +``` + +### 2. Submit Oracle Response (Called by Trusted Nodes) + +```solidity +function submitOracleResponse( + uint256 requestId, + address proposedWinner, + bytes calldata resultData, + bytes calldata signature +) + external + onlyTrustedNode + requestExists(requestId) +{ + OracleRequest storage request = requests[requestId]; + + // Validation + require(request.status == RequestStatus.PENDING, "Request not pending"); + require(!hasSubmitted[requestId][msg.sender], "Already submitted"); + + // Verify signature + require( + verifyNodeSignature(requestId, proposedWinner, resultData, signature, msg.sender), + "Invalid signature" + ); + + // Store submission + submissions[requestId].push(NodeSubmission({ + nodeAddress: msg.sender, + proposedWinner: proposedWinner, + resultData: resultData, + signature: signature, + submittedAt: block.timestamp + })); + + hasSubmitted[requestId][msg.sender] = true; + + emit NodeSubmissionReceived(requestId, msg.sender, proposedWinner); + + // Check if consensus threshold reached + if (submissions[requestId].length >= consensusThreshold) { + _checkConsensusAndSettle(requestId); + } +} +``` + +### 3. Check Consensus and Settle + +```solidity +function _checkConsensusAndSettle(uint256 requestId) internal { + OracleRequest storage request = requests[requestId]; + NodeSubmission[] storage subs = submissions[requestId]; + + // Count votes for each proposed winner + mapping(address => uint256) memory voteCounts; + address[] memory candidates = new address[](subs.length); + uint256 candidateCount = 0; + + for (uint256 i = 0; i < subs.length; i++) { + address candidate = subs[i].proposedWinner; + + // Add to candidates if not already present + bool exists = false; + for (uint256 j = 0; j < candidateCount; j++) { + if (candidates[j] == candidate) { + exists = true; + break; + } + } + + if (!exists) { + candidates[candidateCount] = candidate; + candidateCount++; + } + + voteCounts[candidate]++; + } + + // Find winner with most votes + address consensusWinner = address(0); + uint256 maxVotes = 0; + + for (uint256 i = 0; i < candidateCount; i++) { + if (voteCounts[candidates[i]] > maxVotes) { + maxVotes = voteCounts[candidates[i]]; + consensusWinner = candidates[i]; + } + } + + // Verify consensus threshold + if (maxVotes >= consensusThreshold) { + // Consensus reached - settle the bet + request.consensusWinner = consensusWinner; + request.status = RequestStatus.FULFILLED; + request.fulfilledAt = block.timestamp; + + // Call BetEscrow to settle + IBetEscrow(betEscrowContract).settleBet(request.betId, consensusWinner); + + emit ConsensusReached(requestId, consensusWinner, maxVotes); + emit OracleFulfilled(requestId, request.betId, consensusWinner); + } else { + // No consensus - mark as disputed + request.status = RequestStatus.DISPUTED; + + emit OracleDisputed(requestId, "No consensus reached"); + } +} +``` + +### 4. Verify Node Signature + +```solidity +function verifyNodeSignature( + uint256 requestId, + address proposedWinner, + bytes memory resultData, + bytes memory signature, + address nodeAddress +) internal pure returns (bool) { + // Reconstruct the message hash + bytes32 messageHash = keccak256( + abi.encodePacked(requestId, proposedWinner, resultData) + ); + + // Add Ethereum signed message prefix + bytes32 ethSignedMessageHash = keccak256( + abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash) + ); + + // Recover signer from signature + address recoveredSigner = recoverSigner(ethSignedMessageHash, signature); + + // Verify signer matches node address + return recoveredSigner == nodeAddress; +} + +function recoverSigner(bytes32 ethSignedMessageHash, bytes memory signature) + internal + pure + returns (address) +{ + (bytes32 r, bytes32 s, uint8 v) = splitSignature(signature); + return ecrecover(ethSignedMessageHash, v, r, s); +} + +function splitSignature(bytes memory sig) + internal + pure + returns (bytes32 r, bytes32 s, uint8 v) +{ + require(sig.length == 65, "Invalid signature length"); + + assembly { + r := mload(add(sig, 32)) + s := mload(add(sig, 64)) + v := byte(0, mload(add(sig, 96))) + } +} +``` + +### 5. Manual Dispute Resolution + +```solidity +function resolveDispute(uint256 requestId, address winner) + external + onlyOwner + requestExists(requestId) +{ + OracleRequest storage request = requests[requestId]; + + require( + request.status == RequestStatus.DISPUTED, + "Only for disputed requests" + ); + + // Admin manually resolves dispute + request.consensusWinner = winner; + request.status = RequestStatus.FULFILLED; + request.fulfilledAt = block.timestamp; + + // Settle the bet via BetEscrow admin function + IBetEscrow(betEscrowContract).adminSettleBet(request.betId, winner); + + emit OracleFulfilled(requestId, request.betId, winner); +} +``` + +### 6. Handle Timeout + +```solidity +function markAsTimedOut(uint256 requestId) + external + requestExists(requestId) +{ + OracleRequest storage request = requests[requestId]; + + require(request.status == RequestStatus.PENDING, "Not pending"); + require( + block.timestamp >= request.requestedAt + 24 hours, + "Timeout period not reached" + ); + + request.status = RequestStatus.TIMED_OUT; + + emit OracleDisputed(requestId, "Oracle timed out"); + + // Note: BetEscrow contract allows manual settlement after timeout +} +``` + +## Oracle Network Management + +### Add Trusted Node + +```solidity +function addTrustedNode(address node) external onlyOwner { + require(!isNodeTrusted[node], "Node already trusted"); + + trustedNodes.push(node); + isNodeTrusted[node] = true; + + emit TrustedNodeAdded(node); +} +``` + +### Remove Trusted Node + +```solidity +function removeTrustedNode(address node) external onlyOwner { + require(isNodeTrusted[node], "Node not trusted"); + + isNodeTrusted[node] = false; + + // Remove from array + for (uint256 i = 0; i < trustedNodes.length; i++) { + if (trustedNodes[i] == node) { + trustedNodes[i] = trustedNodes[trustedNodes.length - 1]; + trustedNodes.pop(); + break; + } + } + + emit TrustedNodeRemoved(node); +} +``` + +### Update Consensus Threshold + +```solidity +function setConsensusThreshold(uint256 newThreshold) external onlyOwner { + require(newThreshold > 0, "Threshold must be positive"); + require(newThreshold <= trustedNodes.length, "Threshold too high"); + + consensusThreshold = newThreshold; +} +``` + +## API Adapter Functions + +### Get API Endpoint for Event + +```solidity +mapping(bytes32 => ApiAdapter) public apiAdapters; + +function getApiEndpointForEvent(bytes32 eventId) + internal + view + returns (string memory) +{ + ApiAdapter storage adapter = apiAdapters[eventId]; + require(bytes(adapter.apiEndpoint).length > 0, "No adapter for event"); + + return adapter.apiEndpoint; +} +``` + +### Register API Adapter + +```solidity +function registerApiAdapter( + bytes32 eventId, + string memory apiEndpoint, + string memory resultPath +) external onlyOwner { + apiAdapters[eventId] = ApiAdapter({ + apiEndpoint: apiEndpoint, + resultPath: resultPath + }); +} +``` + +## View Functions + +```solidity +function getRequest(uint256 requestId) + external + view + requestExists(requestId) + returns (OracleRequest memory) +{ + return requests[requestId]; +} + +function getSubmissions(uint256 requestId) + external + view + requestExists(requestId) + returns (NodeSubmission[] memory) +{ + return submissions[requestId]; +} + +function getTrustedNodes() external view returns (address[] memory) { + return trustedNodes; +} + +function getVoteCounts(uint256 requestId) + external + view + requestExists(requestId) + returns (address[] memory candidates, uint256[] memory votes) +{ + NodeSubmission[] storage subs = submissions[requestId]; + + // Count unique candidates + address[] memory tempCandidates = new address[](subs.length); + uint256[] memory tempVotes = new uint256[](subs.length); + uint256 candidateCount = 0; + + for (uint256 i = 0; i < subs.length; i++) { + address candidate = subs[i].proposedWinner; + + // Find or create candidate + bool found = false; + for (uint256 j = 0; j < candidateCount; j++) { + if (tempCandidates[j] == candidate) { + tempVotes[j]++; + found = true; + break; + } + } + + if (!found) { + tempCandidates[candidateCount] = candidate; + tempVotes[candidateCount] = 1; + candidateCount++; + } + } + + // Trim arrays to actual size + candidates = new address[](candidateCount); + votes = new uint256[](candidateCount); + + for (uint256 i = 0; i < candidateCount; i++) { + candidates[i] = tempCandidates[i]; + votes[i] = tempVotes[i]; + } + + return (candidates, votes); +} +``` + +## Oracle Flow Diagram + +``` + BetEscrow.requestSettlement() + ↓ + OracleRequested event emitted + ↓ + ┌──────────┴──────────┐ + │ │ +Node 1 Node 2 Node 3 ... Node N + │ │ │ +Fetch API Fetch API Fetch API + │ │ │ +Sign Result Sign Result Sign Result + │ │ │ + └──────────┬──────────┘ │ + ↓ │ + submitOracleResponse() ←────────────────────┘ + ↓ + Check submissions count + ↓ + ┌──────────┴──────────┐ + │ │ +Count < threshold Count >= threshold + │ │ +Wait for more ↓ +submissions _checkConsensusAndSettle() + ↓ + Count votes per winner + ↓ + ┌─────────┴─────────┐ + │ │ + Consensus No consensus + │ │ + ↓ ↓ + BetEscrow.settleBet() DISPUTED status + ↓ + Funds distributed +``` + +## Security Considerations + +1. **Sybil Resistance**: Only trusted nodes can submit results +2. **Signature Verification**: All node submissions must be cryptographically signed +3. **Consensus Mechanism**: Require majority agreement (e.g., 3 of 5 nodes) +4. **Replay Protection**: Track submissions per request to prevent double-voting +5. **Timeout Handling**: Allow manual settlement if oracle network fails +6. **Admin Override**: Owner can resolve disputes for edge cases + +## Example API Adapters + +### Sports (ESPN API) + +```solidity +Event ID: keccak256("nfl-super-bowl-2024") +API Endpoint: "https://api.espn.com/v1/sports/football/nfl/scoreboard?event=12345" +Result Path: "events[0].competitions[0].winner.team.name" +Result Mapping: { + "San Francisco 49ers": creatorAddress, + "Kansas City Chiefs": opponentAddress +} +``` + +### Entertainment (Awards API) + +```solidity +Event ID: keccak256("oscars-2024-best-picture") +API Endpoint: "https://api.oscars.com/winners/2024" +Result Path: "categories.best_picture.winner" +Result Mapping: { + "Oppenheimer": creatorAddress, + "Other": opponentAddress +} +``` + +## Deployment Configuration + +```solidity +constructor(address _betEscrowContract, uint256 _consensusThreshold) { + owner = msg.sender; + betEscrowContract = _betEscrowContract; + consensusThreshold = _consensusThreshold; +} + +function setBetEscrowContract(address _newContract) external onlyOwner { + betEscrowContract = _newContract; +} +``` + +## Gas Optimization Notes + +- Minimize storage writes in submission loop +- Use events for off-chain data indexing instead of storing all submissions +- Consider using Chainlink oracle infrastructure for production +- Batch consensus checks instead of checking on every submission diff --git a/backend/data/app/blockchain/contracts/README.md b/backend/data/app/blockchain/contracts/README.md new file mode 100644 index 0000000..577e756 --- /dev/null +++ b/backend/data/app/blockchain/contracts/README.md @@ -0,0 +1,455 @@ +# H2H Blockchain Smart Contract Architecture + +## Overview + +This directory contains pseudocode specifications for the H2H betting platform's smart contract layer. The contracts implement a **hybrid blockchain architecture** where critical escrow and settlement operations occur on-chain for trustlessness, while user experience and auxiliary features remain off-chain for performance. + +## Architecture Goals + +1. **Trustless Escrow**: Funds are held by smart contracts, not a centralized authority +2. **Automatic Settlement**: Oracle network fetches real-world event results and settles bets +3. **Transparency**: All bet states and fund movements are publicly verifiable on-chain +4. **Decentralization**: Multiple independent oracle nodes prevent single point of failure +5. **Dispute Resolution**: Multi-layered fallback for edge cases and disagreements + +## Contract Components + +### 1. BetEscrow Contract + +**Purpose**: Manages the complete lifecycle of bets including creation, matching, escrow, and settlement. + +**Key Features**: +- Bet creation with configurable odds and stake amounts +- Atomic escrow locking for both parties upon bet acceptance +- Oracle-delegated settlement +- Manual settlement fallback if oracle fails +- Dispute mechanism with 48-hour window +- Admin override for disputed bets + +**State Machine**: +``` +OPEN → MATCHED → PENDING_ORACLE → COMPLETED + ↓ ↑ +CANCELLED DISPUTED +``` + +[See BetEscrow.pseudocode.md for full implementation](./BetEscrow.pseudocode.md) + +### 2. BetOracle Contract + +**Purpose**: Bridges blockchain and external data sources through a decentralized oracle network. + +**Key Features**: +- Multi-node consensus mechanism (e.g., 3 of 5 nodes must agree) +- Cryptographic signature verification for all submissions +- Support for multiple API adapters (sports, entertainment, politics, etc.) +- Automatic settlement upon consensus +- Timeout handling with manual fallback + +**Oracle Flow**: +``` +Request → Nodes Fetch Data → Submit Results → Verify Consensus → Settle Bet +``` + +[See BetOracle.pseudocode.md for full implementation](./BetOracle.pseudocode.md) + +## System Architecture Diagram + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Frontend (React + Web3) │ +│ ┌──────────────┐ ┌──────────────┐ ┌────────────────────────┐ │ +│ │ MetaMask │ │ Transaction │ │ Blockchain Status │ │ +│ │ Connection │ │ Signing │ │ Badges & Gas Estimates │ │ +│ └──────────────┘ └──────────────┘ └────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ + ↓ ↑ + Web3 JSON-RPC + ↓ ↑ +┌─────────────────────────────────────────────────────────────────────┐ +│ Blockchain Layer (Smart Contracts) │ +│ │ +│ ┌────────────────────────┐ ┌─────────────────────────┐ │ +│ │ BetEscrow Contract │ ←────→ │ BetOracle Contract │ │ +│ │ │ │ │ │ +│ │ • createBet() │ │ • requestSettlement() │ │ +│ │ • acceptBet() │ │ • submitResponse() │ │ +│ │ • settleBet() │ │ • checkConsensus() │ │ +│ │ • disputeBet() │ │ • verifySignatures() │ │ +│ │ │ │ │ │ +│ │ Escrow: ETH/tokens │ │ Trusted Nodes: 5 │ │ +│ └────────────────────────┘ └─────────────────────────┘ │ +│ ↓ Events ↑ API Calls │ +└──────────────┼──────────────────────────────┼──────────────────────┘ + │ │ + │ (BetCreated, BetMatched, │ (Oracle Requests) + │ BetSettled events) │ + ↓ │ +┌─────────────────────────────────────────────┼──────────────────────┐ +│ Backend (FastAPI) │ │ +│ │ │ +│ ┌────────────────────┐ ┌───────────────┴────────┐ │ +│ │ Event Indexer │ │ Oracle Aggregator │ │ +│ │ │ │ │ │ +│ │ • Poll blocks │ │ • Collect node votes │ │ +│ │ • Index events │ │ • Verify consensus │ │ +│ │ • Sync to DB │ │ • Submit to chain │ │ +│ └────────────────────┘ └────────────────────────┘ │ +│ ↓ ↑ │ +│ PostgreSQL Oracle Nodes (3-5) │ +│ ┌────────────────────┐ ┌────────────────────┐ │ +│ │ Cached Bet Data │ │ • Fetch ESPN API │ │ +│ │ (for fast queries)│ │ • Sign results │ │ +│ └────────────────────┘ │ • Submit to chain │ │ +│ └────────────────────┘ │ +│ ↑ │ +└──────────────────────────────────────┼──────────────────────────────┘ + │ + External APIs + ┌─────────────────┴────────────────┐ + │ │ + ┌──────┴──────┐ ┌─────────┴────────┐ + │ ESPN API │ │ Odds API │ + │ (Sports) │ │ (Politics, etc.)│ + └─────────────┘ └──────────────────┘ +``` + +## Complete Bet Lifecycle + +### 1. Bet Creation + +``` +User (Alice) + ↓ + Frontend: Fill out CreateBetModal + ↓ + Web3: Sign transaction + ↓ + BetEscrow.createBet(stake=100, odds=1.5, eventId="super-bowl-2024") + ↓ + Emit BetCreated event + ↓ + Backend Indexer: Sync to PostgreSQL + ↓ + Marketplace: Bet appears as "OPEN" +``` + +**On-Chain State**: +- Bet ID: 42 +- Status: OPEN +- Creator: Alice's address +- Opponent: null +- Escrow: 0 ETH (no funds locked yet) + +**Off-Chain State (DB)**: +- Title: "Super Bowl LVIII Winner" +- Description: "49ers vs Chiefs..." +- blockchain_bet_id: 42 +- blockchain_tx_hash: "0xabc123..." + +### 2. Bet Acceptance + +``` +User (Bob) + ↓ + Frontend: Click "Accept Bet" on BetDetails page + ↓ + Display GasFeeEstimate component + ↓ + User confirms + ↓ + Web3: Sign transaction with stake amount (100 ETH) + ↓ + BetEscrow.acceptBet(betId=42) payable + ↓ + Contract transfers Alice's 100 ETH from her approved balance + Contract receives Bob's 100 ETH from msg.value + ↓ + Escrow locked: 200 ETH total + ↓ + Emit BetMatched event + ↓ + Frontend: Show TransactionModal ("Confirming...") + ↓ + Wait for block confirmation (10-30 seconds) + ↓ + TransactionModal: "Success!" with Etherscan link + ↓ + Backend Indexer: Update DB status to MATCHED + ↓ + Frontend: Refresh bet details, show "Matched" status +``` + +**On-Chain State**: +- Bet ID: 42 +- Status: MATCHED +- Creator: Alice's address +- Opponent: Bob's address +- Escrow: 200 ETH + +**Off-Chain State (DB)**: +- status: matched +- opponent_id: Bob's user ID +- blockchain_status: "MATCHED" + +### 3. Oracle Settlement (Automatic) + +``` +Event occurs in real world (Super Bowl game ends) + ↓ + BetEscrow.requestSettlement(betId=42) + ↓ + BetOracle.requestSettlement(betId=42, eventId="super-bowl-2024") + ↓ + Emit OracleRequested event + ↓ + ┌─────────────────────────────────────┐ + │ Oracle Nodes (listening for events) │ + └─────────────────────────────────────┘ + ↓ ↓ ↓ + Node 1 Node 2 Node 3 + │ │ │ + Fetch ESPN Fetch ESPN Fetch ESPN + API result API result API result + │ │ │ + "49ers won" "49ers won" "49ers won" + │ │ │ + Map to Alice Map to Alice Map to Alice + │ │ │ + Sign result Sign result Sign result + ↓ ↓ ↓ + BetOracle.submitOracleResponse(requestId, winner=Alice, signature) + ↓ + Check submissions count (3/3) + ↓ + Check consensus (3 votes for Alice) + ↓ + Consensus reached! + ↓ + BetEscrow.settleBet(betId=42, winner=Alice) + ↓ + Transfer 200 ETH to Alice + ↓ + Emit BetSettled event + ↓ + Backend Indexer: Update DB + ↓ + Frontend: Show "Completed" status + ↓ + Alice's wallet balance: +200 ETH +``` + +**On-Chain State**: +- Bet ID: 42 +- Status: COMPLETED +- Winner: Alice's address +- Escrow: 0 ETH (paid out) + +**Off-Chain State (DB)**: +- status: completed +- winner_id: Alice's user ID +- settled_at: timestamp + +## Integration Points + +### Frontend → Blockchain + +**Files**: `frontend/src/blockchain/hooks/useBlockchainBet.ts` + +```typescript +// Example: Accepting a bet +const { acceptBet } = useBlockchainBet() + +await acceptBet(betId=42, stakeAmount=100) + ↓ +MetaMask popup for user signature + ↓ +Transaction submitted to blockchain + ↓ +Wait for confirmation + ↓ +Update UI with new bet status +``` + +### Backend → Blockchain + +**Files**: `backend/app/blockchain/services/blockchain_service.py` + +```python +# Example: Creating bet on-chain +blockchain_service.create_bet_on_chain( + stake_amount=100, + event_id="super-bowl-2024" +) + ↓ +Build transaction with Web3.py + ↓ +Sign with backend hot wallet + ↓ +Submit to RPC endpoint + ↓ +Return transaction hash +``` + +### Blockchain → Backend (Event Indexing) + +**Files**: `backend/app/blockchain/services/blockchain_indexer.py` + +```python +# Continuous background process +while True: + latest_block = get_latest_block() + events = get_events_in_block(latest_block) + + for event in events: + if event.type == "BetCreated": + sync_bet_created_to_db(event) + elif event.type == "BetMatched": + sync_bet_matched_to_db(event) + elif event.type == "BetSettled": + sync_bet_settled_to_db(event) + + sleep(10 seconds) +``` + +## Data Flow: On-Chain vs Off-Chain + +| Data | Storage Location | Reason | +|------|------------------|--------| +| Bet escrow funds | ⛓️ On-Chain | Trustless, no centralized custody | +| Bet status (OPEN/MATCHED/COMPLETED) | ⛓️ On-Chain + 💾 DB Cache | Source of truth on-chain, cached for speed | +| Bet participants (creator/opponent) | ⛓️ On-Chain + 💾 DB | Enforced by smart contract | +| Settlement winner | ⛓️ On-Chain | Oracle consensus, immutable | +| Bet title/description | 💾 DB only | Not needed for contract logic | +| User email/password | 💾 DB only | Authentication separate from blockchain | +| Transaction history | ⛓️ On-Chain (events) + 💾 DB | Events logged on-chain, indexed to DB | +| Search/filters | 💾 DB only | Too expensive to query blockchain | + +## Security Model + +### On-Chain Security + +1. **Escrow Protection**: Funds locked in smart contract, not controlled by any individual +2. **Atomic Operations**: Both parties' funds locked in single transaction (no partial states) +3. **Role-Based Access**: Only oracle can settle, only owner can admin-override +4. **Reentrancy Guards**: Prevent malicious contracts from draining escrow +5. **Signature Verification**: All oracle submissions cryptographically signed + +### Oracle Security + +1. **Multi-Node Consensus**: Require 3 of 5 nodes to agree (prevents single node manipulation) +2. **Signature Verification**: Each node signs results with private key +3. **Timeout Fallback**: If oracle fails, users can manually settle after 24 hours +4. **Dispute Window**: 48 hours to dispute automatic settlement +5. **Admin Override**: Final fallback for edge cases + +### Threat Model + +| Threat | Mitigation | +|--------|-----------| +| Malicious oracle node | Multi-node consensus (3/5 threshold) | +| All oracle nodes fail | 24-hour timeout → manual settlement allowed | +| Disputed result | 48-hour dispute window → admin resolution | +| Smart contract bug | Audited code, time-lock for upgrades | +| User loses private key | Non-custodial, user responsible (standard Web3) | +| Front-running | Commit-reveal scheme (future enhancement) | + +## Gas Costs & Optimization + +### Estimated Gas Usage (Ethereum Mainnet) + +| Operation | Gas Limit | Cost @ 50 gwei | Cost @ 100 gwei | +|-----------|-----------|----------------|-----------------| +| Create Bet | 120,000 | 0.006 ETH ($12) | 0.012 ETH ($24) | +| Accept Bet | 180,000 | 0.009 ETH ($18) | 0.018 ETH ($36) | +| Settle Bet (Oracle) | 150,000 | 0.0075 ETH ($15) | 0.015 ETH ($30) | +| Submit Oracle Response | 80,000 | 0.004 ETH ($8) | 0.008 ETH ($16) | + +### Layer 2 Optimization + +**Recommendation**: Deploy on Polygon or Arbitrum for: +- **90-95% lower gas costs**: Accept bet costs ~$2 instead of $36 +- **Faster confirmations**: 2 seconds vs 12-15 seconds +- **Same security**: Inherits Ethereum security via rollups +- **No code changes**: Same Solidity contracts work on L2 + +**Example L2 Costs (Polygon)**: +- Create Bet: ~$0.50 +- Accept Bet: ~$1.00 +- Settle Bet: ~$0.75 + +## Testing Strategy + +### Unit Tests +- Test each contract function individually +- Verify state transitions (OPEN → MATCHED → COMPLETED) +- Test edge cases (insufficient funds, unauthorized access, etc.) + +### Integration Tests +- Test BetEscrow ↔ BetOracle integration +- Test oracle consensus mechanism with simulated nodes +- Test timeout and fallback scenarios + +### End-to-End Tests +- Deploy contracts to testnet (Sepolia/Mumbai) +- Create bet through frontend +- Accept bet with second account +- Trigger oracle settlement +- Verify funds distributed correctly + +## Deployment Plan + +### Phase 1: Testnet Deployment +1. Deploy contracts to Sepolia (Ethereum) or Mumbai (Polygon) +2. Verify contracts on Etherscan +3. Set up 3 oracle nodes +4. Configure API adapters for test events +5. Test full lifecycle with test ETH + +### Phase 2: Security Audit +1. Engage smart contract auditor (CertiK, OpenZeppelin, Trail of Bits) +2. Fix any discovered vulnerabilities +3. Re-audit critical changes + +### Phase 3: Mainnet Launch +1. Deploy to Ethereum mainnet or Polygon +2. Transfer ownership to multi-sig wallet +3. Launch with limited beta users +4. Monitor for 2 weeks before full launch + +## Future Enhancements + +1. **EIP-2771 Meta-Transactions**: Backend pays gas for user actions +2. **Batch Settlement**: Settle multiple bets in single transaction +3. **Flexible Odds**: Support decimal odds like 1.75x instead of whole numbers +4. **Partial Matching**: Allow bets to be partially filled by multiple opponents +5. **NFT Receipts**: Issue NFTs representing bet participation +6. **DAO Governance**: Community votes on oracle disputes + +## Repository Structure + +``` +backend/app/blockchain/ +├── contracts/ +│ ├── BetEscrow.pseudocode.md # This file +│ ├── BetOracle.pseudocode.md # Oracle contract spec +│ └── README.md # Architecture overview (you are here) +├── services/ +│ ├── blockchain_service.py # Web3 integration +│ ├── blockchain_indexer.py # Event listener +│ ├── oracle_node.py # Oracle node implementation +│ └── oracle_aggregator.py # Consensus aggregator +└── config.py # Contract addresses, RPC URLs +``` + +## References + +- [Ethereum Smart Contracts](https://ethereum.org/en/developers/docs/smart-contracts/) +- [Chainlink Oracles](https://chain.link/education/blockchain-oracles) +- [OpenZeppelin Contracts](https://docs.openzeppelin.com/contracts/) +- [Solidity by Example](https://solidity-by-example.org/) +- [Web3.py Documentation](https://web3py.readthedocs.io/) + +--- + +**Note**: This is pseudocode for architectural planning. For production deployment, these contracts would need to be written in actual Solidity, audited for security, and thoroughly tested on testnets before mainnet launch. diff --git a/backend/data/app/blockchain/services/__init__.py b/backend/data/app/blockchain/services/__init__.py new file mode 100644 index 0000000..8fe1af7 --- /dev/null +++ b/backend/data/app/blockchain/services/__init__.py @@ -0,0 +1,24 @@ +""" +Blockchain Services + +Core services for blockchain integration: +- BlockchainService: Web3 provider and contract interactions +- BlockchainIndexer: Event listener and database sync +- OracleNode: Fetches external API data +- OracleAggregator: Achieves consensus among oracle nodes +""" + +from .blockchain_service import BlockchainService, get_blockchain_service +from .blockchain_indexer import BlockchainIndexer, get_blockchain_indexer +from .oracle_node import OracleNode +from .oracle_aggregator import OracleAggregator, get_oracle_aggregator + +__all__ = [ + "BlockchainService", + "get_blockchain_service", + "BlockchainIndexer", + "get_blockchain_indexer", + "OracleNode", + "OracleAggregator", + "get_oracle_aggregator", +] diff --git a/backend/data/app/blockchain/services/blockchain_indexer.py b/backend/data/app/blockchain/services/blockchain_indexer.py new file mode 100644 index 0000000..e78314c --- /dev/null +++ b/backend/data/app/blockchain/services/blockchain_indexer.py @@ -0,0 +1,427 @@ +""" +Blockchain Event Indexer + +This service continuously polls the blockchain for new events emitted by +BetEscrow and BetOracle contracts, then syncs them to the PostgreSQL database. + +This allows the hybrid architecture to: +1. Use blockchain as source of truth for escrow and settlement +2. Maintain fast queries through cached database records +3. Provide real-time updates to users via WebSocket + +NOTE: This is pseudocode/skeleton showing the architecture. +""" + +import asyncio +from typing import Dict, Any, List +from datetime import datetime + + +class BlockchainIndexer: + """ + Indexes blockchain events and syncs to database. + + Continuously polls for new blocks, extracts events, and updates + the PostgreSQL database to match on-chain state. + """ + + def __init__(self, blockchain_service, database_session): + """ + Initialize indexer. + + Args: + blockchain_service: Instance of BlockchainService + database_session: SQLAlchemy async session + """ + self.blockchain_service = blockchain_service + self.db = database_session + self.is_running = False + self.poll_interval = 10 # seconds + + async def start(self): + """ + Start the indexer background worker. + + This runs continuously, polling for new blocks every 10 seconds. + """ + self.is_running = True + print("Blockchain indexer started") + + try: + while self.is_running: + await self._index_new_blocks() + await asyncio.sleep(self.poll_interval) + except Exception as e: + print(f"Indexer error: {e}") + self.is_running = False + + async def stop(self): + """Stop the indexer.""" + self.is_running = False + print("Blockchain indexer stopped") + + async def _index_new_blocks(self): + """ + Index all new blocks since last indexed block. + + Flow: + 1. Get last indexed block from database + 2. Get current blockchain height + 3. For each new block, extract and process events + 4. Update last indexed block + """ + # Pseudocode: + # + # # Get last indexed block from database + # last_indexed_block = await self.db.execute( + # select(BlockchainSync).order_by(BlockchainSync.block_number.desc()).limit(1) + # ) + # last_block = last_indexed_block.scalar_one_or_none() + # + # if last_block: + # start_block = last_block.block_number + 1 + # else: + # # First time indexing - start from contract deployment block + # start_block = DEPLOYMENT_BLOCK_NUMBER + # + # # Get current blockchain height + # current_block = await self.blockchain_service.web3.eth.block_number + # + # if start_block > current_block: + # return # No new blocks + # + # print(f"Indexing blocks {start_block} to {current_block}") + # + # # Index each block + # for block_num in range(start_block, current_block + 1): + # await self._index_block(block_num) + # + # # Update last indexed block + # sync_record = BlockchainSync( + # block_number=current_block, + # indexed_at=datetime.utcnow() + # ) + # self.db.add(sync_record) + # await self.db.commit() + + # Placeholder for pseudocode + print("[Indexer] Checking for new blocks...") + await asyncio.sleep(0.1) + + async def _index_block(self, block_number: int): + """ + Index all events in a specific block. + + Args: + block_number: Block number to index + """ + # Pseudocode: + # + # # Get BetCreated events + # bet_created_events = await self.blockchain_service.bet_escrow_contract.events.BetCreated.getLogs( + # fromBlock=block_number, + # toBlock=block_number + # ) + # + # for event in bet_created_events: + # await self._handle_bet_created(event) + # + # # Get BetMatched events + # bet_matched_events = await self.blockchain_service.bet_escrow_contract.events.BetMatched.getLogs( + # fromBlock=block_number, + # toBlock=block_number + # ) + # + # for event in bet_matched_events: + # await self._handle_bet_matched(event) + # + # # Get BetSettled events + # bet_settled_events = await self.blockchain_service.bet_escrow_contract.events.BetSettled.getLogs( + # fromBlock=block_number, + # toBlock=block_number + # ) + # + # for event in bet_settled_events: + # await self._handle_bet_settled(event) + # + # # Get BetDisputed events + # bet_disputed_events = await self.blockchain_service.bet_escrow_contract.events.BetDisputed.getLogs( + # fromBlock=block_number, + # toBlock=block_number + # ) + # + # for event in bet_disputed_events: + # await self._handle_bet_disputed(event) + + print(f"[Indexer] Indexing block {block_number}") + await asyncio.sleep(0.1) + + async def _handle_bet_created(self, event: Dict[str, Any]): + """ + Handle BetCreated event. + + Event structure: + { + 'args': { + 'betId': 42, + 'creator': '0xAlice...', + 'stakeAmount': 100000000000000000000, # 100 ETH in wei + 'eventId': '0xsuper-bowl-2024', + 'eventTimestamp': 1704067200 + }, + 'transactionHash': '0xabc123...', + 'blockNumber': 12345 + } + + Syncs to database by updating the bet record with blockchain fields. + """ + # Pseudocode: + # + # bet_id = event['args']['betId'] + # creator_address = event['args']['creator'] + # stake_amount = self.blockchain_service.web3.fromWei(event['args']['stakeAmount'], 'ether') + # tx_hash = event['transactionHash'].hex() + # block_number = event['blockNumber'] + # + # # Find bet in database by creator address + # # (Bet was already created via backend API with local ID) + # user = await self.db.execute( + # select(User).where(User.wallet_address == creator_address) + # ) + # user = user.scalar_one_or_none() + # + # if user: + # # Find the most recent bet created by this user without blockchain_bet_id + # bet = await self.db.execute( + # select(Bet) + # .where(Bet.creator_id == user.id, Bet.blockchain_bet_id.is_(None)) + # .order_by(Bet.created_at.desc()) + # .limit(1) + # ) + # bet = bet.scalar_one_or_none() + # + # if bet: + # # Update with blockchain data + # bet.blockchain_bet_id = bet_id + # bet.blockchain_tx_hash = tx_hash + # bet.blockchain_status = 'OPEN' + # await self.db.commit() + # + # print(f"[Indexer] BetCreated: bet_id={bet_id}, tx={tx_hash}") + + print(f"[Indexer] BetCreated event: {event}") + await asyncio.sleep(0.1) + + async def _handle_bet_matched(self, event: Dict[str, Any]): + """ + Handle BetMatched event. + + Event structure: + { + 'args': { + 'betId': 42, + 'opponent': '0xBob...', + 'totalEscrow': 200000000000000000000 # 200 ETH in wei + }, + 'transactionHash': '0xdef456...', + 'blockNumber': 12346 + } + + Updates bet status to MATCHED and records opponent. + """ + # Pseudocode: + # + # bet_id = event['args']['betId'] + # opponent_address = event['args']['opponent'] + # total_escrow = self.blockchain_service.web3.fromWei(event['args']['totalEscrow'], 'ether') + # tx_hash = event['transactionHash'].hex() + # + # # Find bet by blockchain_bet_id + # bet = await self.db.execute( + # select(Bet).where(Bet.blockchain_bet_id == bet_id) + # ) + # bet = bet.scalar_one_or_none() + # + # if bet: + # # Find opponent user by wallet address + # opponent = await self.db.execute( + # select(User).where(User.wallet_address == opponent_address) + # ) + # opponent = opponent.scalar_one_or_none() + # + # if opponent: + # bet.opponent_id = opponent.id + # bet.status = BetStatus.MATCHED + # bet.blockchain_status = 'MATCHED' + # await self.db.commit() + # + # # Create transaction records for both parties + # await create_escrow_lock_transaction(bet.creator_id, bet.stake_amount, bet.id) + # await create_escrow_lock_transaction(opponent.id, bet.stake_amount, bet.id) + # + # # Send WebSocket notification + # await send_websocket_event("bet_matched", {"bet_id": bet.id}) + # + # print(f"[Indexer] BetMatched: bet_id={bet_id}, opponent={opponent_address}") + + print(f"[Indexer] BetMatched event: {event}") + await asyncio.sleep(0.1) + + async def _handle_bet_settled(self, event: Dict[str, Any]): + """ + Handle BetSettled event. + + Event structure: + { + 'args': { + 'betId': 42, + 'winner': '0xAlice...', + 'payoutAmount': 200000000000000000000 # 200 ETH in wei + }, + 'transactionHash': '0xghi789...', + 'blockNumber': 12347 + } + + Updates bet to COMPLETED and records winner. + """ + # Pseudocode: + # + # bet_id = event['args']['betId'] + # winner_address = event['args']['winner'] + # payout_amount = self.blockchain_service.web3.fromWei(event['args']['payoutAmount'], 'ether') + # tx_hash = event['transactionHash'].hex() + # + # # Find bet + # bet = await self.db.execute( + # select(Bet).where(Bet.blockchain_bet_id == bet_id) + # ) + # bet = bet.scalar_one_or_none() + # + # if bet: + # # Find winner user + # winner = await self.db.execute( + # select(User).where(User.wallet_address == winner_address) + # ) + # winner = winner.scalar_one_or_none() + # + # if winner: + # bet.winner_id = winner.id + # bet.status = BetStatus.COMPLETED + # bet.blockchain_status = 'COMPLETED' + # bet.settled_at = datetime.utcnow() + # await self.db.commit() + # + # # Create transaction records + # loser_id = bet.opponent_id if winner.id == bet.creator_id else bet.creator_id + # + # await create_bet_won_transaction(winner.id, payout_amount, bet.id) + # await create_bet_lost_transaction(loser_id, bet.stake_amount, bet.id) + # + # # Update user stats + # await update_user_stats(winner.id, won=True) + # await update_user_stats(loser_id, won=False) + # + # # Send WebSocket notifications + # await send_websocket_event("bet_settled", {"bet_id": bet.id, "winner_id": winner.id}) + # + # print(f"[Indexer] BetSettled: bet_id={bet_id}, winner={winner_address}") + + print(f"[Indexer] BetSettled event: {event}") + await asyncio.sleep(0.1) + + async def _handle_bet_disputed(self, event: Dict[str, Any]): + """ + Handle BetDisputed event. + + Event structure: + { + 'args': { + 'betId': 42, + 'disputedBy': '0xBob...', + 'timestamp': 1704153600 + }, + 'transactionHash': '0xjkl012...', + 'blockNumber': 12348 + } + + Updates bet status to DISPUTED. + """ + # Pseudocode: + # + # bet_id = event['args']['betId'] + # disputed_by_address = event['args']['disputedBy'] + # + # bet = await self.db.execute( + # select(Bet).where(Bet.blockchain_bet_id == bet_id) + # ) + # bet = bet.scalar_one_or_none() + # + # if bet: + # bet.status = BetStatus.DISPUTED + # bet.blockchain_status = 'DISPUTED' + # await self.db.commit() + # + # # Notify admins for manual review + # await send_admin_notification("Bet disputed", {"bet_id": bet.id}) + # + # print(f"[Indexer] BetDisputed: bet_id={bet_id}, disputed_by={disputed_by_address}") + + print(f"[Indexer] BetDisputed event: {event}") + await asyncio.sleep(0.1) + + async def reindex_from_block(self, start_block: int): + """ + Reindex blockchain events from a specific block. + + Useful for recovering from indexer downtime or bugs. + + Args: + start_block: Block number to start reindexing from + """ + # Pseudocode: + # + # current_block = await self.blockchain_service.web3.eth.block_number + # + # print(f"Reindexing from block {start_block} to {current_block}") + # + # for block_num in range(start_block, current_block + 1): + # await self._index_block(block_num) + # if block_num % 100 == 0: + # print(f"Progress: {block_num}/{current_block}") + # + # print("Reindexing complete") + + print(f"[Indexer] Reindexing from block {start_block}") + await asyncio.sleep(0.1) + + +# Singleton instance +_indexer: BlockchainIndexer | None = None + + +def get_blockchain_indexer(blockchain_service, database_session) -> BlockchainIndexer: + """Get singleton indexer instance.""" + global _indexer + + if _indexer is None: + _indexer = BlockchainIndexer(blockchain_service, database_session) + + return _indexer + + +async def start_indexer_background_task(blockchain_service, database_session): + """ + Start the indexer as a background task. + + This would be called from main.py on application startup: + + @app.on_event("startup") + async def startup_event(): + blockchain_service = get_blockchain_service() + db_session = get_database_session() + + indexer = get_blockchain_indexer(blockchain_service, db_session) + asyncio.create_task(indexer.start()) + """ + indexer = get_blockchain_indexer(blockchain_service, database_session) + await indexer.start() diff --git a/backend/data/app/blockchain/services/blockchain_service.py b/backend/data/app/blockchain/services/blockchain_service.py new file mode 100644 index 0000000..847201a --- /dev/null +++ b/backend/data/app/blockchain/services/blockchain_service.py @@ -0,0 +1,466 @@ +""" +Blockchain Service - Bridge between H2H Backend and Smart Contracts + +This service provides a Web3 integration layer for interacting with BetEscrow +and BetOracle smart contracts. It handles transaction building, signing, and +submission to the blockchain. + +NOTE: This is pseudocode/skeleton showing the architecture. +In production, you would use web3.py library. +""" + +from decimal import Decimal +from typing import Dict, Any, Optional +import asyncio + + +class BlockchainService: + """ + Main service for blockchain interactions. + + Responsibilities: + - Connect to blockchain RPC endpoint + - Load contract ABIs and addresses + - Build and sign transactions + - Estimate gas costs + - Submit transactions and wait for confirmation + """ + + def __init__( + self, + rpc_url: str, + bet_escrow_address: str, + bet_oracle_address: str, + private_key: str + ): + """ + Initialize blockchain service. + + Args: + rpc_url: Blockchain RPC endpoint (e.g., Infura, Alchemy) + bet_escrow_address: Deployed BetEscrow contract address + bet_oracle_address: Deployed BetOracle contract address + private_key: Private key for backend signer account + """ + # Pseudocode: Initialize Web3 connection + # self.web3 = Web3(HTTPProvider(rpc_url)) + # self.account = Account.from_key(private_key) + + # Load contract ABIs and create contract instances + # self.bet_escrow_contract = self.web3.eth.contract( + # address=bet_escrow_address, + # abi=load_abi("BetEscrow") + # ) + # self.bet_oracle_contract = self.web3.eth.contract( + # address=bet_oracle_address, + # abi=load_abi("BetOracle") + # ) + + self.rpc_url = rpc_url + self.bet_escrow_address = bet_escrow_address + self.bet_oracle_address = bet_oracle_address + self.signer_address = "DERIVED_FROM_PRIVATE_KEY" + + async def create_bet_on_chain( + self, + stake_amount: Decimal, + creator_odds: float, + opponent_odds: float, + event_timestamp: int, + event_id: str + ) -> Dict[str, Any]: + """ + Create a bet on the blockchain. + + This function: + 1. Builds the createBet transaction + 2. Estimates gas cost + 3. Signs the transaction with backend wallet + 4. Submits to blockchain + 5. Waits for confirmation + 6. Parses event logs to get bet ID + + Args: + stake_amount: Amount each party stakes (in ETH or tokens) + creator_odds: Odds multiplier for creator (e.g., 1.5) + opponent_odds: Odds multiplier for opponent (e.g., 2.0) + event_timestamp: Unix timestamp when event occurs + event_id: External event identifier for oracle + + Returns: + Dict containing: + - bet_id: On-chain bet ID + - tx_hash: Transaction hash + - block_number: Block number where bet was created + - gas_used: Actual gas consumed + """ + # Pseudocode implementation: + # + # # Convert stake to wei + # stake_wei = self.web3.toWei(stake_amount, 'ether') + # + # # Build transaction + # tx = self.bet_escrow_contract.functions.createBet( + # stake_wei, + # int(creator_odds * 100), # Scale to avoid decimals + # int(opponent_odds * 100), + # event_timestamp, + # self.web3.toBytes(text=event_id) + # ).buildTransaction({ + # 'from': self.account.address, + # 'gas': 200000, + # 'gasPrice': await self.web3.eth.gas_price, + # 'nonce': await self.web3.eth.get_transaction_count(self.account.address) + # }) + # + # # Sign transaction + # signed_tx = self.web3.eth.account.sign_transaction(tx, self.account.key) + # + # # Send transaction + # tx_hash = await self.web3.eth.send_raw_transaction(signed_tx.rawTransaction) + # + # # Wait for receipt + # receipt = await self.web3.eth.wait_for_transaction_receipt(tx_hash) + # + # # Parse BetCreated event + # event_log = self.bet_escrow_contract.events.BetCreated().processReceipt(receipt) + # bet_id = event_log[0]['args']['betId'] + # + # return { + # 'bet_id': bet_id, + # 'tx_hash': tx_hash.hex(), + # 'block_number': receipt['blockNumber'], + # 'gas_used': receipt['gasUsed'] + # } + + # Placeholder return for pseudocode + await asyncio.sleep(0.1) # Simulate async operation + return { + 'bet_id': 42, + 'tx_hash': '0xabc123...', + 'block_number': 12345, + 'gas_used': 150000 + } + + async def prepare_accept_bet_transaction( + self, + bet_id: int, + user_address: str, + stake_amount: Decimal + ) -> Dict[str, Any]: + """ + Prepare acceptBet transaction for user to sign in frontend. + + This returns an unsigned transaction that the frontend will + send to MetaMask for the user to sign. + + Args: + bet_id: On-chain bet ID + user_address: User's wallet address (from MetaMask) + stake_amount: Stake amount to send with transaction + + Returns: + Unsigned transaction dict ready for MetaMask + """ + # Pseudocode: + # + # stake_wei = self.web3.toWei(stake_amount, 'ether') + # + # tx = self.bet_escrow_contract.functions.acceptBet(bet_id).buildTransaction({ + # 'from': user_address, + # 'value': stake_wei, + # 'gas': 300000, + # 'gasPrice': await self.web3.eth.gas_price, + # 'nonce': await self.web3.eth.get_transaction_count(user_address) + # }) + # + # return tx # Frontend will sign this with MetaMask + + await asyncio.sleep(0.1) + return { + 'to': self.bet_escrow_address, + 'from': user_address, + 'value': int(stake_amount * 10**18), # wei + 'gas': 300000, + 'gasPrice': 50000000000, # 50 gwei + 'data': '0x...' # Encoded function call + } + + async def request_settlement( + self, + bet_id: int + ) -> str: + """ + Request oracle settlement for a bet. + + Called after the real-world event has occurred. + This triggers the oracle network to fetch results and settle. + + Args: + bet_id: On-chain bet ID + + Returns: + Transaction hash + """ + # Pseudocode: + # + # tx = self.bet_escrow_contract.functions.requestSettlement(bet_id).buildTransaction({ + # 'from': self.account.address, + # 'gas': 100000, + # 'gasPrice': await self.web3.eth.gas_price, + # 'nonce': await self.web3.eth.get_transaction_count(self.account.address) + # }) + # + # signed_tx = self.web3.eth.account.sign_transaction(tx, self.account.key) + # tx_hash = await self.web3.eth.send_raw_transaction(signed_tx.rawTransaction) + # + # return tx_hash.hex() + + await asyncio.sleep(0.1) + return '0xdef456...' + + async def settle_bet_via_oracle( + self, + request_id: int, + bet_id: int, + winner_address: str, + result_data: bytes, + signatures: list + ) -> str: + """ + Submit oracle settlement to blockchain. + + Called by oracle aggregator after consensus is reached. + + Args: + request_id: Oracle request ID + bet_id: On-chain bet ID + winner_address: Winner's wallet address + result_data: Raw API result data + signatures: Signatures from oracle nodes + + Returns: + Transaction hash + """ + # Pseudocode: + # + # tx = self.bet_oracle_contract.functions.fulfillSettlement( + # request_id, + # bet_id, + # winner_address, + # result_data, + # signatures + # ).buildTransaction({ + # 'from': self.account.address, + # 'gas': 250000, + # 'gasPrice': await self.web3.eth.gas_price, + # 'nonce': await self.web3.eth.get_transaction_count(self.account.address) + # }) + # + # signed_tx = self.web3.eth.account.sign_transaction(tx, self.account.key) + # tx_hash = await self.web3.eth.send_raw_transaction(signed_tx.rawTransaction) + # + # return tx_hash.hex() + + await asyncio.sleep(0.1) + return '0xghi789...' + + async def get_bet_from_chain(self, bet_id: int) -> Dict[str, Any]: + """ + Fetch bet details from blockchain. + + Calls the getBet view function on BetEscrow contract. + + Args: + bet_id: On-chain bet ID + + Returns: + Dict with bet details: + - bet_id + - creator (address) + - opponent (address) + - stake_amount + - status (enum value) + - winner (address) + - created_at (timestamp) + """ + # Pseudocode: + # + # bet = await self.bet_escrow_contract.functions.getBet(bet_id).call() + # + # return { + # 'bet_id': bet[0], + # 'creator': bet[1], + # 'opponent': bet[2], + # 'stake_amount': self.web3.fromWei(bet[3], 'ether'), + # 'status': bet[4], # Enum value (0=OPEN, 1=MATCHED, etc.) + # 'creator_odds': bet[5] / 100.0, + # 'opponent_odds': bet[6] / 100.0, + # 'created_at': bet[7], + # 'event_timestamp': bet[8], + # 'event_id': bet[9], + # 'winner': bet[10], + # 'settled_at': bet[11] + # } + + await asyncio.sleep(0.1) + return { + 'bet_id': bet_id, + 'creator': '0xAlice...', + 'opponent': '0xBob...', + 'stake_amount': Decimal('100'), + 'status': 1, # MATCHED + 'winner': None + } + + async def get_user_escrow(self, user_address: str) -> Decimal: + """ + Get total amount user has locked in escrow across all bets. + + Args: + user_address: User's wallet address + + Returns: + Total escrow amount in ETH + """ + # Pseudocode: + # + # escrow_wei = await self.bet_escrow_contract.functions.getUserEscrow( + # user_address + # ).call() + # + # return self.web3.fromWei(escrow_wei, 'ether') + + await asyncio.sleep(0.1) + return Decimal('75.00') + + async def estimate_gas( + self, + transaction_type: str, + **kwargs + ) -> Dict[str, Any]: + """ + Estimate gas cost for a transaction. + + Args: + transaction_type: Type of transaction (create_bet, accept_bet, etc.) + **kwargs: Transaction-specific parameters + + Returns: + Dict with: + - gas_limit: Estimated gas units + - gas_price: Current gas price in wei + - cost_eth: Total cost in ETH + - cost_usd: Total cost in USD (fetched from price oracle) + """ + # Pseudocode: + # + # # Build unsigned transaction + # if transaction_type == "create_bet": + # tx = self.bet_escrow_contract.functions.createBet(...).buildTransaction(...) + # elif transaction_type == "accept_bet": + # tx = self.bet_escrow_contract.functions.acceptBet(...).buildTransaction(...) + # + # # Estimate gas + # gas_limit = await self.web3.eth.estimate_gas(tx) + # gas_price = await self.web3.eth.gas_price + # + # cost_wei = gas_limit * gas_price + # cost_eth = self.web3.fromWei(cost_wei, 'ether') + # + # # Fetch ETH price from oracle + # eth_price_usd = await self.get_eth_price_usd() + # cost_usd = float(cost_eth) * eth_price_usd + # + # return { + # 'gas_limit': gas_limit, + # 'gas_price': gas_price, + # 'cost_eth': cost_eth, + # 'cost_usd': cost_usd + # } + + await asyncio.sleep(0.1) + + # Gas estimates for different operations + estimates = { + 'create_bet': 120000, + 'accept_bet': 180000, + 'settle_bet': 150000 + } + + gas_limit = estimates.get(transaction_type, 100000) + gas_price = 50000000000 # 50 gwei + cost_wei = gas_limit * gas_price + cost_eth = Decimal(cost_wei) / Decimal(10**18) + + return { + 'gas_limit': gas_limit, + 'gas_price': gas_price, + 'cost_eth': cost_eth, + 'cost_usd': float(cost_eth) * 2000 # Assume $2000/ETH + } + + async def get_transaction_receipt(self, tx_hash: str) -> Optional[Dict[str, Any]]: + """ + Get transaction receipt (confirmation status). + + Args: + tx_hash: Transaction hash + + Returns: + Receipt dict or None if not yet mined + """ + # Pseudocode: + # + # try: + # receipt = await self.web3.eth.get_transaction_receipt(tx_hash) + # return { + # 'status': receipt['status'], # 1 = success, 0 = failed + # 'block_number': receipt['blockNumber'], + # 'gas_used': receipt['gasUsed'], + # 'logs': receipt['logs'] + # } + # except TransactionNotFound: + # return None + + await asyncio.sleep(0.1) + return { + 'status': 1, + 'block_number': 12345, + 'gas_used': 150000, + 'logs': [] + } + + +# Singleton instance +_blockchain_service: Optional[BlockchainService] = None + + +def get_blockchain_service() -> BlockchainService: + """ + Get singleton blockchain service instance. + + In production, this would load config from environment variables. + """ + global _blockchain_service + + if _blockchain_service is None: + # Pseudocode: Load from config + # from app.blockchain.config import BLOCKCHAIN_CONFIG + # + # _blockchain_service = BlockchainService( + # rpc_url=BLOCKCHAIN_CONFIG['rpc_url'], + # bet_escrow_address=BLOCKCHAIN_CONFIG['bet_escrow_address'], + # bet_oracle_address=BLOCKCHAIN_CONFIG['bet_oracle_address'], + # private_key=BLOCKCHAIN_CONFIG['backend_private_key'] + # ) + + # Placeholder for pseudocode + _blockchain_service = BlockchainService( + rpc_url="https://eth-mainnet.alchemyapi.io/v2/YOUR-API-KEY", + bet_escrow_address="0x1234567890abcdef...", + bet_oracle_address="0xfedcba0987654321...", + private_key="BACKEND_PRIVATE_KEY" + ) + + return _blockchain_service diff --git a/backend/data/app/blockchain/services/oracle_aggregator.py b/backend/data/app/blockchain/services/oracle_aggregator.py new file mode 100644 index 0000000..f5d3349 --- /dev/null +++ b/backend/data/app/blockchain/services/oracle_aggregator.py @@ -0,0 +1,481 @@ +""" +Oracle Aggregator - Consensus Coordinator + +This service collects oracle submissions from multiple nodes, verifies consensus, +and submits the final result to the blockchain. + +Flow: +1. Receive submissions from oracle nodes (via HTTP API) +2. Verify each submission's signature +3. Count votes for each proposed winner +4. Check if consensus threshold is met (e.g., 3 of 5 nodes) +5. Submit consensus result to BetOracle contract +6. If no consensus, mark request as disputed + +NOTE: This is pseudocode/skeleton showing the architecture. +""" + +import asyncio +from typing import Dict, List, Any, Optional +from collections import defaultdict +from datetime import datetime + + +class OracleSubmission: + """Represents a single oracle node's submission.""" + + def __init__( + self, + node_id: str, + node_address: str, + winner_address: str, + result_data: Dict[str, Any], + signature: str, + submitted_at: datetime + ): + self.node_id = node_id + self.node_address = node_address + self.winner_address = winner_address + self.result_data = result_data + self.signature = signature + self.submitted_at = submitted_at + + +class OracleAggregator: + """ + Aggregates oracle submissions and achieves consensus. + + Maintains state of all active oracle requests and their submissions. + """ + + def __init__( + self, + blockchain_service, + consensus_threshold: int = 3, + total_nodes: int = 5 + ): + """ + Initialize aggregator. + + Args: + blockchain_service: Instance of BlockchainService + consensus_threshold: Minimum nodes that must agree (e.g., 3) + total_nodes: Total number of oracle nodes (e.g., 5) + """ + self.blockchain_service = blockchain_service + self.consensus_threshold = consensus_threshold + self.total_nodes = total_nodes + + # Track submissions per request + # request_id => [OracleSubmission, ...] + self.submissions: Dict[int, List[OracleSubmission]] = defaultdict(list) + + # Track which nodes have submitted for each request + # request_id => {node_address: bool} + self.node_submitted: Dict[int, Dict[str, bool]] = defaultdict(dict) + + # Trusted oracle node addresses + self.trusted_nodes = set([ + "0xNode1Address...", + "0xNode2Address...", + "0xNode3Address...", + "0xNode4Address...", + "0xNode5Address..." + ]) + + async def receive_submission( + self, + request_id: int, + node_id: str, + node_address: str, + winner_address: str, + result_data: Dict[str, Any], + signature: str + ) -> Dict[str, Any]: + """ + Receive and process an oracle node submission. + + This is called by the HTTP API endpoint when nodes submit results. + + Args: + request_id: Oracle request ID + node_id: Node identifier + node_address: Node's wallet address + winner_address: Proposed winner address + result_data: API result data + signature: Node's cryptographic signature + + Returns: + Dict with status and any consensus result + """ + # Verify node is trusted + if node_address not in self.trusted_nodes: + return { + 'status': 'error', + 'message': 'Untrusted node' + } + + # Verify node hasn't already submitted + if self.node_submitted[request_id].get(node_address, False): + return { + 'status': 'error', + 'message': 'Node already submitted' + } + + # Verify signature + is_valid = await self._verify_signature( + request_id, + winner_address, + result_data, + signature, + node_address + ) + + if not is_valid: + return { + 'status': 'error', + 'message': 'Invalid signature' + } + + # Store submission + submission = OracleSubmission( + node_id=node_id, + node_address=node_address, + winner_address=winner_address, + result_data=result_data, + signature=signature, + submitted_at=datetime.utcnow() + ) + + self.submissions[request_id].append(submission) + self.node_submitted[request_id][node_address] = True + + print(f"[Aggregator] Received submission from {node_id} for request {request_id}") + print(f"[Aggregator] {len(self.submissions[request_id])}/{self.total_nodes} nodes submitted") + + # Check if consensus threshold reached + if len(self.submissions[request_id]) >= self.consensus_threshold: + result = await self._check_consensus_and_settle(request_id) + return { + 'status': 'success', + 'consensus': result + } + + return { + 'status': 'pending', + 'submissions_count': len(self.submissions[request_id]), + 'threshold': self.consensus_threshold + } + + async def _verify_signature( + self, + request_id: int, + winner_address: str, + result_data: Dict[str, Any], + signature: str, + node_address: str + ) -> bool: + """ + Verify that the signature is valid for this node. + + Args: + request_id: Oracle request ID + winner_address: Proposed winner + result_data: API result + signature: Signature to verify + node_address: Expected signer address + + Returns: + True if signature is valid + """ + # Pseudocode: + # + # from eth_account.messages import encode_defunct, defunct_hash_message + # from eth_account import Account + # import json + # import hashlib + # + # # Reconstruct message + # message = f"{request_id}{winner_address}{json.dumps(result_data, sort_keys=True)}" + # message_hash = hashlib.sha256(message.encode()).hexdigest() + # + # # Recover signer from signature + # signable_message = encode_defunct(text=message_hash) + # recovered_address = Account.recover_message(signable_message, signature=signature) + # + # # Verify signer matches node address + # return recovered_address.lower() == node_address.lower() + + # Placeholder - assume valid + return True + + async def _check_consensus_and_settle(self, request_id: int) -> Dict[str, Any]: + """ + Check if consensus is reached and settle the bet. + + Steps: + 1. Count votes for each proposed winner + 2. Find winner with most votes + 3. Verify threshold met + 4. Submit to blockchain if consensus + 5. Mark as disputed if no consensus + + Args: + request_id: Oracle request ID + + Returns: + Dict with consensus result + """ + submissions = self.submissions[request_id] + + # Count votes for each winner + vote_counts: Dict[str, int] = defaultdict(int) + vote_details: Dict[str, List[str]] = defaultdict(list) + + for submission in submissions: + winner = submission.winner_address + vote_counts[winner] += 1 + vote_details[winner].append(submission.node_id) + + # Find winner with most votes + consensus_winner = max(vote_counts, key=vote_counts.get) + consensus_votes = vote_counts[consensus_winner] + + print(f"[Aggregator] Vote results for request {request_id}:") + for winner, count in vote_counts.items(): + nodes = vote_details[winner] + print(f" {winner}: {count} votes from {nodes}") + + # Check if threshold met + if consensus_votes >= self.consensus_threshold: + print(f"[Aggregator] Consensus reached! Winner: {consensus_winner} ({consensus_votes}/{self.total_nodes} votes)") + + # Get bet ID from request + # In production, fetch from blockchain or database + bet_id = await self._get_bet_id_for_request(request_id) + + # Collect signatures for blockchain submission + signatures = [ + sub.signature + for sub in submissions + if sub.winner_address == consensus_winner + ] + + # Get result data from consensus submissions + result_data = submissions[0].result_data # Use first submission's data + + # Submit to blockchain + tx_hash = await self.blockchain_service.settle_bet_via_oracle( + request_id=request_id, + bet_id=bet_id, + winner_address=consensus_winner, + result_data=str(result_data).encode(), + signatures=signatures + ) + + print(f"[Aggregator] Submitted settlement to blockchain: {tx_hash}") + + # Clean up submissions for this request + del self.submissions[request_id] + del self.node_submitted[request_id] + + return { + 'consensus': True, + 'winner': consensus_winner, + 'votes': consensus_votes, + 'tx_hash': tx_hash + } + + else: + print(f"[Aggregator] No consensus for request {request_id}") + print(f" Best: {consensus_winner} with {consensus_votes} votes (need {self.consensus_threshold})") + + # Mark as disputed + await self._mark_as_disputed(request_id) + + return { + 'consensus': False, + 'reason': 'Insufficient consensus', + 'votes': dict(vote_counts) + } + + async def _get_bet_id_for_request(self, request_id: int) -> int: + """ + Get bet ID associated with oracle request. + + In production, this would query the BetOracle contract + or database to get the bet_id for this request_id. + + Args: + request_id: Oracle request ID + + Returns: + Bet ID + """ + # Pseudocode: + # + # request = await self.blockchain_service.bet_oracle_contract.functions.getRequest( + # request_id + # ).call() + # + # return request['betId'] + + # Placeholder + return 42 + + async def _mark_as_disputed(self, request_id: int): + """ + Mark oracle request as disputed. + + Sends notification to admin for manual review. + + Args: + request_id: Oracle request ID + """ + # Pseudocode: + # + # # Update database + # await db.execute( + # update(OracleRequest) + # .where(OracleRequest.request_id == request_id) + # .values(status='DISPUTED') + # ) + # + # # Notify admins + # await send_admin_notification( + # "Oracle Dispute", + # f"Request {request_id} - no consensus reached" + # ) + # + # # Could also call blockchain contract to mark as disputed + # await self.blockchain_service.bet_oracle_contract.functions.markAsDisputed( + # request_id + # ).send() + + print(f"[Aggregator] Marked request {request_id} as disputed") + + async def get_request_status(self, request_id: int) -> Dict[str, Any]: + """ + Get status of an oracle request. + + Args: + request_id: Oracle request ID + + Returns: + Dict with current status and submissions + """ + submissions = self.submissions.get(request_id, []) + + vote_counts: Dict[str, int] = defaultdict(int) + for sub in submissions: + vote_counts[sub.winner_address] += 1 + + return { + 'request_id': request_id, + 'submissions_count': len(submissions), + 'threshold': self.consensus_threshold, + 'votes': dict(vote_counts), + 'nodes_submitted': [sub.node_id for sub in submissions] + } + + async def handle_timeout(self, request_id: int): + """ + Handle oracle request timeout. + + Called if nodes fail to respond within time limit (e.g., 1 hour). + + Args: + request_id: Oracle request ID + """ + submissions = self.submissions.get(request_id, []) + + if len(submissions) == 0: + print(f"[Aggregator] Request {request_id} timed out with no submissions") + else: + print(f"[Aggregator] Request {request_id} timed out with {len(submissions)} submissions") + # Try to settle with available submissions + await self._check_consensus_and_settle(request_id) + + # Mark as timed out on blockchain + # Pseudocode: + # await self.blockchain_service.bet_oracle_contract.functions.markAsTimedOut( + # request_id + # ).send() + + +# Singleton instance +_aggregator: Optional[OracleAggregator] = None + + +def get_oracle_aggregator(blockchain_service) -> OracleAggregator: + """Get singleton aggregator instance.""" + global _aggregator + + if _aggregator is None: + _aggregator = OracleAggregator( + blockchain_service=blockchain_service, + consensus_threshold=3, + total_nodes=5 + ) + + return _aggregator + + +# FastAPI endpoint example +""" +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +router = APIRouter() + +class OracleSubmissionRequest(BaseModel): + request_id: int + node_id: str + node_address: str + winner_address: str + result_data: dict + signature: str + + +@router.post("/oracle/submit") +async def submit_oracle_result(submission: OracleSubmissionRequest): + \""" + API endpoint for oracle nodes to submit results. + + Called by oracle_node.py when a node has fetched and signed a result. + \""" + from app.blockchain.services.blockchain_service import get_blockchain_service + + blockchain_service = get_blockchain_service() + aggregator = get_oracle_aggregator(blockchain_service) + + result = await aggregator.receive_submission( + request_id=submission.request_id, + node_id=submission.node_id, + node_address=submission.node_address, + winner_address=submission.winner_address, + result_data=submission.result_data, + signature=submission.signature + ) + + if result['status'] == 'error': + raise HTTPException(status_code=400, detail=result['message']) + + return result + + +@router.get("/oracle/status/{request_id}") +async def get_oracle_status(request_id: int): + \""" + Get status of an oracle request. + + Shows how many nodes have submitted and current vote counts. + \""" + from app.blockchain.services.blockchain_service import get_blockchain_service + + blockchain_service = get_blockchain_service() + aggregator = get_oracle_aggregator(blockchain_service) + + status = await aggregator.get_request_status(request_id) + return status +""" diff --git a/backend/data/app/blockchain/services/oracle_node.py b/backend/data/app/blockchain/services/oracle_node.py new file mode 100644 index 0000000..eeafaa4 --- /dev/null +++ b/backend/data/app/blockchain/services/oracle_node.py @@ -0,0 +1,471 @@ +""" +Oracle Node - Decentralized Data Provider + +This service acts as one node in the oracle network. It: +1. Listens for OracleRequested events from BetOracle contract +2. Fetches data from external APIs (sports, entertainment, etc.) +3. Determines the winner based on API results +4. Signs the result with node's private key +5. Submits the signed result to oracle aggregator + +Multiple independent oracle nodes run this code to create a decentralized +oracle network with consensus-based settlement. + +NOTE: This is pseudocode/skeleton showing the architecture. +""" + +import asyncio +import hashlib +from typing import Dict, Any, Optional +from datetime import datetime +import json + + +class OracleNode: + """ + Oracle node for fetching and submitting external data. + + Each node operates independently and signs its results. + The aggregator collects results from all nodes and achieves consensus. + """ + + def __init__( + self, + node_id: str, + private_key: str, + blockchain_service, + aggregator_url: str + ): + """ + Initialize oracle node. + + Args: + node_id: Unique identifier for this node + private_key: Node's private key for signing results + blockchain_service: Instance of BlockchainService + aggregator_url: URL of oracle aggregator service + """ + self.node_id = node_id + self.private_key = private_key + self.blockchain_service = blockchain_service + self.aggregator_url = aggregator_url + self.is_running = False + + # API adapters for different event types + self.api_adapters = { + 'sports': self._fetch_sports_result, + 'entertainment': self._fetch_entertainment_result, + 'politics': self._fetch_politics_result, + 'custom': self._fetch_custom_result + } + + async def start(self): + """ + Start the oracle node. + + Continuously listens for OracleRequested events. + """ + self.is_running = True + print(f"Oracle node {self.node_id} started") + + try: + while self.is_running: + await self._listen_for_oracle_requests() + await asyncio.sleep(5) + except Exception as e: + print(f"Oracle node error: {e}") + self.is_running = False + + async def stop(self): + """Stop the oracle node.""" + self.is_running = False + print(f"Oracle node {self.node_id} stopped") + + async def _listen_for_oracle_requests(self): + """ + Listen for OracleRequested events from blockchain. + + When an event is detected, process the oracle request. + """ + # Pseudocode: + # + # # Create event filter for OracleRequested + # event_filter = self.blockchain_service.bet_oracle_contract.events.OracleRequested.create_filter( + # fromBlock='latest' + # ) + # + # # Get new events + # events = event_filter.get_new_entries() + # + # for event in events: + # request_id = event['args']['requestId'] + # bet_id = event['args']['betId'] + # event_id = event['args']['eventId'] + # api_endpoint = event['args']['apiEndpoint'] + # + # print(f"[Oracle {self.node_id}] Received request {request_id} for bet {bet_id}") + # + # # Process request asynchronously + # asyncio.create_task( + # self._process_oracle_request(request_id, bet_id, event_id, api_endpoint) + # ) + + # Placeholder + await asyncio.sleep(0.1) + + async def _process_oracle_request( + self, + request_id: int, + bet_id: int, + event_id: str, + api_endpoint: str + ): + """ + Process an oracle request. + + Steps: + 1. Fetch bet details from blockchain + 2. Call external API + 3. Parse result to determine winner + 4. Sign the result + 5. Submit to aggregator + + Args: + request_id: Oracle request ID + bet_id: On-chain bet ID + event_id: External event identifier + api_endpoint: API URL to fetch result from + """ + try: + # Get bet details + bet = await self.blockchain_service.get_bet_from_chain(bet_id) + + # Determine API adapter type from event_id + # event_id format: "sports:nfl-super-bowl-2024" + adapter_type = event_id.split(':')[0] if ':' in event_id else 'custom' + + # Fetch API result + api_result = await self._fetch_api_result(adapter_type, api_endpoint, event_id) + + if not api_result: + print(f"[Oracle {self.node_id}] Failed to fetch API result for {event_id}") + return + + # Determine winner from API result + winner_address = await self._determine_winner(api_result, bet) + + if not winner_address: + print(f"[Oracle {self.node_id}] Could not determine winner from API result") + return + + # Sign the result + signature = self._sign_result(request_id, winner_address, api_result) + + # Submit to aggregator + await self._submit_to_aggregator( + request_id, + winner_address, + api_result, + signature + ) + + print(f"[Oracle {self.node_id}] Submitted result for request {request_id}") + + except Exception as e: + print(f"[Oracle {self.node_id}] Error processing request {request_id}: {e}") + + async def _fetch_api_result( + self, + adapter_type: str, + api_endpoint: str, + event_id: str + ) -> Optional[Dict[str, Any]]: + """ + Fetch result from external API. + + Args: + adapter_type: Type of API (sports, entertainment, etc.) + api_endpoint: API URL + event_id: Event identifier + + Returns: + Parsed API response dict or None if failed + """ + adapter_function = self.api_adapters.get(adapter_type, self._fetch_custom_result) + return await adapter_function(api_endpoint, event_id) + + async def _fetch_sports_result( + self, + api_endpoint: str, + event_id: str + ) -> Optional[Dict[str, Any]]: + """ + Fetch sports event result from ESPN API or similar. + + Example ESPN API response: + { + "event_id": "nfl-super-bowl-2024", + "status": "final", + "winner": { + "team_name": "San Francisco 49ers", + "score": 24 + }, + "loser": { + "team_name": "Kansas City Chiefs", + "score": 21 + } + } + + Args: + api_endpoint: ESPN API URL + event_id: Event identifier + + Returns: + Parsed result dict + """ + # Pseudocode: + # + # import httpx + # + # async with httpx.AsyncClient() as client: + # response = await client.get(api_endpoint, timeout=10.0) + # + # if response.status_code != 200: + # return None + # + # data = response.json() + # + # # Extract winner from response + # # Path depends on API structure + # winner_team = data['events'][0]['competitions'][0]['winner']['team']['displayName'] + # + # return { + # 'event_id': event_id, + # 'winner': winner_team, + # 'status': 'final', + # 'timestamp': datetime.utcnow().isoformat() + # } + + # Placeholder for pseudocode + await asyncio.sleep(0.1) + return { + 'event_id': event_id, + 'winner': 'San Francisco 49ers', + 'status': 'final' + } + + async def _fetch_entertainment_result( + self, + api_endpoint: str, + event_id: str + ) -> Optional[Dict[str, Any]]: + """ + Fetch entertainment event result (awards, box office, etc.). + + Example Oscars API response: + { + "year": 2024, + "category": "Best Picture", + "winner": "Oppenheimer", + "nominees": ["Oppenheimer", "Killers of the Flower Moon", ...] + } + """ + # Pseudocode: + # + # async with httpx.AsyncClient() as client: + # response = await client.get(api_endpoint, timeout=10.0) + # data = response.json() + # + # winner = data['categories']['best_picture']['winner'] + # + # return { + # 'event_id': event_id, + # 'winner': winner, + # 'category': 'Best Picture' + # } + + await asyncio.sleep(0.1) + return { + 'event_id': event_id, + 'winner': 'Oppenheimer', + 'category': 'Best Picture' + } + + async def _fetch_politics_result( + self, + api_endpoint: str, + event_id: str + ) -> Optional[Dict[str, Any]]: + """Fetch political event result (elections, votes, etc.).""" + await asyncio.sleep(0.1) + return { + 'event_id': event_id, + 'winner': 'Candidate A' + } + + async def _fetch_custom_result( + self, + api_endpoint: str, + event_id: str + ) -> Optional[Dict[str, Any]]: + """Fetch result from custom user-specified API.""" + await asyncio.sleep(0.1) + return { + 'event_id': event_id, + 'result': 'Custom result' + } + + async def _determine_winner( + self, + api_result: Dict[str, Any], + bet: Dict[str, Any] + ) -> Optional[str]: + """ + Map API result to bet participant address. + + Compares API result with bet positions to determine winner. + + Args: + api_result: Result from external API + bet: Bet details from blockchain + + Returns: + Winner's wallet address or None if cannot determine + """ + # Pseudocode: + # + # # Get creator and opponent positions from bet metadata (stored off-chain) + # bet_metadata = await get_bet_metadata_from_db(bet['bet_id']) + # + # creator_position = bet_metadata['creator_position'] # e.g., "49ers win" + # opponent_position = bet_metadata['opponent_position'] # e.g., "Chiefs win" + # + # api_winner = api_result.get('winner') + # + # # Simple string matching (production would use NLP/fuzzy matching) + # if "49ers" in creator_position and "49ers" in api_winner: + # return bet['creator'] + # elif "Chiefs" in creator_position and "Chiefs" in api_winner: + # return bet['creator'] + # else: + # return bet['opponent'] + + # Placeholder - assume creator won + return bet['creator'] + + def _sign_result( + self, + request_id: int, + winner_address: str, + result_data: Dict[str, Any] + ) -> str: + """ + Sign the oracle result with node's private key. + + This proves that this specific oracle node submitted this result. + + Args: + request_id: Oracle request ID + winner_address: Winner's address + result_data: API result data + + Returns: + Hex-encoded signature + """ + # Pseudocode: + # + # from eth_account.messages import encode_defunct + # from eth_account import Account + # + # # Create message hash + # message = f"{request_id}{winner_address}{json.dumps(result_data, sort_keys=True)}" + # message_hash = hashlib.sha256(message.encode()).hexdigest() + # + # # Sign with private key + # signable_message = encode_defunct(text=message_hash) + # signed_message = Account.sign_message(signable_message, private_key=self.private_key) + # + # return signed_message.signature.hex() + + # Placeholder + message = f"{request_id}{winner_address}{json.dumps(result_data, sort_keys=True)}" + signature_hash = hashlib.sha256(message.encode()).hexdigest() + return f"0x{signature_hash}" + + async def _submit_to_aggregator( + self, + request_id: int, + winner_address: str, + result_data: Dict[str, Any], + signature: str + ): + """ + Submit signed result to oracle aggregator. + + The aggregator collects results from all nodes and checks consensus. + + Args: + request_id: Oracle request ID + winner_address: Winner's address + result_data: API result data + signature: Node's signature + """ + # Pseudocode: + # + # import httpx + # + # payload = { + # 'request_id': request_id, + # 'node_id': self.node_id, + # 'node_address': self.node_address, # Derived from private_key + # 'winner_address': winner_address, + # 'result_data': result_data, + # 'signature': signature + # } + # + # async with httpx.AsyncClient() as client: + # response = await client.post( + # f"{self.aggregator_url}/oracle/submit", + # json=payload, + # timeout=10.0 + # ) + # + # if response.status_code == 200: + # print(f"[Oracle {self.node_id}] Result submitted successfully") + # else: + # print(f"[Oracle {self.node_id}] Failed to submit: {response.text}") + + print(f"[Oracle {self.node_id}] Submitting result to aggregator...") + await asyncio.sleep(0.1) + + +# Example: Running multiple oracle nodes +async def start_oracle_node(node_id: str, private_key: str): + """ + Start an oracle node instance. + + In production, you would run 3-5 nodes on different servers + with different API keys and infrastructure for redundancy. + + Example: + # Node 1 (AWS us-east-1) + await start_oracle_node("oracle-node-1", "PRIVATE_KEY_1") + + # Node 2 (GCP us-west-2) + await start_oracle_node("oracle-node-2", "PRIVATE_KEY_2") + + # Node 3 (Azure eu-west-1) + await start_oracle_node("oracle-node-3", "PRIVATE_KEY_3") + """ + from .blockchain_service import get_blockchain_service + + blockchain_service = get_blockchain_service() + + node = OracleNode( + node_id=node_id, + private_key=private_key, + blockchain_service=blockchain_service, + aggregator_url="https://aggregator.h2h.com" + ) + + await node.start() diff --git a/backend/data/app/config.py b/backend/data/app/config.py new file mode 100644 index 0000000..42765c4 --- /dev/null +++ b/backend/data/app/config.py @@ -0,0 +1,14 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + DATABASE_URL: str = "sqlite+aiosqlite:///./data/h2h.db" + JWT_SECRET: str = "your-secret-key-change-in-production-min-32-chars" + JWT_ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 + REFRESH_TOKEN_EXPIRE_DAYS: int = 7 + + model_config = SettingsConfigDict(env_file=".env", extra="allow") + + +settings = Settings() diff --git a/backend/data/app/crud/__init__.py b/backend/data/app/crud/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/data/app/crud/bet.py b/backend/data/app/crud/bet.py new file mode 100644 index 0000000..2ea8121 --- /dev/null +++ b/backend/data/app/crud/bet.py @@ -0,0 +1,79 @@ +from sqlalchemy import select, or_ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload +from app.models import Bet, BetStatus, BetCategory +from app.schemas.bet import BetCreate + + +async def get_bet_by_id(db: AsyncSession, bet_id: int) -> Bet | None: + result = await db.execute( + select(Bet) + .options( + selectinload(Bet.creator), + selectinload(Bet.opponent) + ) + .where(Bet.id == bet_id) + ) + return result.scalar_one_or_none() + + +async def get_open_bets( + db: AsyncSession, + skip: int = 0, + limit: int = 20, + category: BetCategory | None = None, +) -> list[Bet]: + query = select(Bet).where(Bet.status == BetStatus.OPEN) + + if category: + query = query.where(Bet.category == category) + + query = query.options( + selectinload(Bet.creator) + ).offset(skip).limit(limit).order_by(Bet.created_at.desc()) + + result = await db.execute(query) + return list(result.scalars().all()) + + +async def get_user_bets( + db: AsyncSession, + user_id: int, + status: BetStatus | None = None, +) -> list[Bet]: + query = select(Bet).where( + or_(Bet.creator_id == user_id, Bet.opponent_id == user_id) + ) + + if status: + query = query.where(Bet.status == status) + + query = query.options( + selectinload(Bet.creator), + selectinload(Bet.opponent) + ).order_by(Bet.created_at.desc()) + + result = await db.execute(query) + return list(result.scalars().all()) + + +async def create_bet(db: AsyncSession, bet_data: BetCreate, user_id: int) -> Bet: + bet = Bet( + creator_id=user_id, + title=bet_data.title, + description=bet_data.description, + category=bet_data.category, + event_name=bet_data.event_name, + event_date=bet_data.event_date, + creator_position=bet_data.creator_position, + opponent_position=bet_data.opponent_position, + creator_odds=bet_data.creator_odds, + opponent_odds=bet_data.opponent_odds, + stake_amount=bet_data.stake_amount, + visibility=bet_data.visibility, + expires_at=bet_data.expires_at, + ) + db.add(bet) + await db.flush() + await db.refresh(bet) + return bet diff --git a/backend/data/app/crud/user.py b/backend/data/app/crud/user.py new file mode 100644 index 0000000..64d9e0d --- /dev/null +++ b/backend/data/app/crud/user.py @@ -0,0 +1,56 @@ +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from app.models import User, Wallet +from app.schemas.user import UserCreate +from app.utils.security import get_password_hash +from decimal import Decimal + + +async def get_user_by_id(db: AsyncSession, user_id: int) -> User | None: + result = await db.execute(select(User).where(User.id == user_id)) + return result.scalar_one_or_none() + + +async def get_user_by_email(db: AsyncSession, email: str) -> User | None: + result = await db.execute(select(User).where(User.email == email)) + return result.scalar_one_or_none() + + +async def get_user_by_username(db: AsyncSession, username: str) -> User | None: + result = await db.execute(select(User).where(User.username == username)) + return result.scalar_one_or_none() + + +async def create_user(db: AsyncSession, user_data: UserCreate) -> User: + user = User( + email=user_data.email, + username=user_data.username, + password_hash=get_password_hash(user_data.password), + display_name=user_data.display_name or user_data.username, + ) + db.add(user) + await db.flush() + + # Create wallet for user + wallet = Wallet( + user_id=user.id, + balance=Decimal("0.00"), + escrow=Decimal("0.00"), + ) + db.add(wallet) + await db.flush() + await db.refresh(user) + + return user + + +async def update_user_stats(db: AsyncSession, user_id: int, won: bool) -> None: + user = await get_user_by_id(db, user_id) + if user: + user.total_bets += 1 + if won: + user.wins += 1 + else: + user.losses += 1 + user.win_rate = user.wins / user.total_bets if user.total_bets > 0 else 0.0 + await db.flush() diff --git a/backend/data/app/crud/wallet.py b/backend/data/app/crud/wallet.py new file mode 100644 index 0000000..7945b02 --- /dev/null +++ b/backend/data/app/crud/wallet.py @@ -0,0 +1,54 @@ +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload +from decimal import Decimal +from app.models import Wallet, Transaction, TransactionType, TransactionStatus + + +async def get_user_wallet(db: AsyncSession, user_id: int) -> Wallet | None: + result = await db.execute( + select(Wallet).where(Wallet.user_id == user_id) + ) + return result.scalar_one_or_none() + + +async def get_wallet_transactions( + db: AsyncSession, + user_id: int, + limit: int = 50, + offset: int = 0 +) -> list[Transaction]: + result = await db.execute( + select(Transaction) + .where(Transaction.user_id == user_id) + .order_by(Transaction.created_at.desc()) + .limit(limit) + .offset(offset) + ) + return list(result.scalars().all()) + + +async def create_transaction( + db: AsyncSession, + user_id: int, + wallet_id: int, + transaction_type: TransactionType, + amount: Decimal, + balance_after: Decimal, + description: str, + reference_id: int | None = None, + status: TransactionStatus = TransactionStatus.COMPLETED, +) -> Transaction: + transaction = Transaction( + user_id=user_id, + wallet_id=wallet_id, + type=transaction_type, + amount=amount, + balance_after=balance_after, + reference_id=reference_id, + description=description, + status=status, + ) + db.add(transaction) + await db.flush() + return transaction diff --git a/backend/data/app/database.py b/backend/data/app/database.py new file mode 100644 index 0000000..819f6ac --- /dev/null +++ b/backend/data/app/database.py @@ -0,0 +1,38 @@ +from typing import AsyncGenerator +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker +from sqlalchemy.orm import DeclarativeBase +from app.config import settings + + +engine = create_async_engine( + settings.DATABASE_URL, + echo=True, + connect_args={"check_same_thread": False} if "sqlite" in settings.DATABASE_URL else {} +) + +async_session = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False +) + + +class Base(DeclarativeBase): + pass + + +async def get_db() -> AsyncGenerator[AsyncSession, None]: + async with async_session() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + finally: + await session.close() + + +async def init_db(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) diff --git a/backend/data/app/main.py b/backend/data/app/main.py new file mode 100644 index 0000000..d8cb996 --- /dev/null +++ b/backend/data/app/main.py @@ -0,0 +1,50 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from contextlib import asynccontextmanager +from app.database import init_db +from app.routers import auth, users, wallet, bets, websocket, admin, sport_events, spread_bets + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup + await init_db() + yield + # Shutdown + + +app = FastAPI( + title="H2H Betting Platform API", + description="Peer-to-peer betting platform MVP", + version="1.0.0", + lifespan=lifespan +) + +# CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Include routers +app.include_router(auth.router) +app.include_router(users.router) +app.include_router(wallet.router) +app.include_router(bets.router) +app.include_router(websocket.router) +app.include_router(admin.router) +app.include_router(sport_events.router) +app.include_router(spread_bets.router) + + +@app.get("/") +async def root(): + return {"message": "H2H Betting Platform API", "version": "1.0.0"} + + +@app.get("/health") +async def health(): + return {"status": "healthy"} diff --git a/backend/data/app/models/__init__.py b/backend/data/app/models/__init__.py new file mode 100644 index 0000000..e3497e8 --- /dev/null +++ b/backend/data/app/models/__init__.py @@ -0,0 +1,29 @@ +from app.models.user import User, UserStatus +from app.models.wallet import Wallet +from app.models.transaction import Transaction, TransactionType, TransactionStatus +from app.models.bet import Bet, BetProposal, BetCategory, BetStatus, BetVisibility, ProposalStatus +from app.models.sport_event import SportEvent, SportType, EventStatus +from app.models.spread_bet import SpreadBet, SpreadBetStatus, TeamSide +from app.models.admin_settings import AdminSettings + +__all__ = [ + "User", + "UserStatus", + "Wallet", + "Transaction", + "TransactionType", + "TransactionStatus", + "Bet", + "BetProposal", + "BetCategory", + "BetStatus", + "BetVisibility", + "ProposalStatus", + "SportEvent", + "SportType", + "EventStatus", + "SpreadBet", + "SpreadBetStatus", + "TeamSide", + "AdminSettings", +] diff --git a/backend/data/app/models/admin_settings.py b/backend/data/app/models/admin_settings.py new file mode 100644 index 0000000..a5d2834 --- /dev/null +++ b/backend/data/app/models/admin_settings.py @@ -0,0 +1,32 @@ +from sqlalchemy import String, Numeric, Float, Integer +from sqlalchemy.orm import Mapped, mapped_column +from decimal import Decimal +from app.database import Base + + +class AdminSettings(Base): + """Global admin settings - single row configuration table""" + __tablename__ = "admin_settings" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + + # House commission settings + default_house_commission_percent: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=Decimal("10.00")) + + # Default betting limits + default_min_bet_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), default=Decimal("10.00")) + default_max_bet_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), default=Decimal("1000.00")) + + # Default spread range for new events + default_min_spread: Mapped[float] = mapped_column(Float, default=-10.0) + default_max_spread: Mapped[float] = mapped_column(Float, default=10.0) + + # Spread increment (0.5 or 1.0) + spread_increment: Mapped[float] = mapped_column(Float, default=0.5) + + # Platform settings + platform_name: Mapped[str] = mapped_column(String(100), default="H2H Sports Betting") + maintenance_mode: Mapped[bool] = mapped_column(default=False) + + # Note: This should be a single-row table + # Access via: await db.execute(select(AdminSettings).limit(1)) diff --git a/backend/data/app/models/bet.py b/backend/data/app/models/bet.py new file mode 100644 index 0000000..e7b03b3 --- /dev/null +++ b/backend/data/app/models/bet.py @@ -0,0 +1,98 @@ +from sqlalchemy import ForeignKey, Numeric, String, DateTime, Enum, Float +from sqlalchemy.orm import Mapped, mapped_column, relationship +from datetime import datetime +from decimal import Decimal +import enum +from app.database import Base + + +class BetCategory(enum.Enum): + SPORTS = "sports" + ESPORTS = "esports" + POLITICS = "politics" + ENTERTAINMENT = "entertainment" + CUSTOM = "custom" + + +class BetStatus(enum.Enum): + OPEN = "open" + MATCHED = "matched" + IN_PROGRESS = "in_progress" + PENDING_RESULT = "pending_result" + COMPLETED = "completed" + CANCELLED = "cancelled" + DISPUTED = "disputed" + + +class BetVisibility(enum.Enum): + PUBLIC = "public" + PRIVATE = "private" + FRIENDS_ONLY = "friends_only" + + +class Bet(Base): + __tablename__ = "bets" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + creator_id: Mapped[int] = mapped_column(ForeignKey("users.id")) + opponent_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) + + title: Mapped[str] = mapped_column(String(200)) + description: Mapped[str] = mapped_column(String(2000)) + category: Mapped[BetCategory] = mapped_column(Enum(BetCategory)) + + # Event info + event_name: Mapped[str] = mapped_column(String(200)) + event_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + + # Terms + creator_position: Mapped[str] = mapped_column(String(500)) + opponent_position: Mapped[str] = mapped_column(String(500)) + creator_odds: Mapped[float] = mapped_column(Float, default=1.0) + opponent_odds: Mapped[float] = mapped_column(Float, default=1.0) + + # Stake + stake_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2)) + currency: Mapped[str] = mapped_column(String(3), default="USD") + + status: Mapped[BetStatus] = mapped_column(Enum(BetStatus), default=BetStatus.OPEN) + visibility: Mapped[BetVisibility] = mapped_column(Enum(BetVisibility), default=BetVisibility.PUBLIC) + + # Result + winner_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) + settled_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + settled_by: Mapped[str | None] = mapped_column(String(20), nullable=True) + + expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + creator: Mapped["User"] = relationship(back_populates="created_bets", foreign_keys=[creator_id]) + opponent: Mapped["User"] = relationship(back_populates="accepted_bets", foreign_keys=[opponent_id]) + proposals: Mapped[list["BetProposal"]] = relationship(back_populates="bet") + + +class ProposalStatus(enum.Enum): + PENDING = "pending" + ACCEPTED = "accepted" + REJECTED = "rejected" + EXPIRED = "expired" + + +class BetProposal(Base): + __tablename__ = "bet_proposals" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + bet_id: Mapped[int] = mapped_column(ForeignKey("bets.id")) + proposer_id: Mapped[int] = mapped_column(ForeignKey("users.id")) + proposed_stake: Mapped[Decimal] = mapped_column(Numeric(12, 2)) + proposed_creator_odds: Mapped[float] = mapped_column(Float) + proposed_opponent_odds: Mapped[float] = mapped_column(Float) + message: Mapped[str | None] = mapped_column(String(500), nullable=True) + status: Mapped[ProposalStatus] = mapped_column(Enum(ProposalStatus), default=ProposalStatus.PENDING) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + expires_at: Mapped[datetime] = mapped_column(DateTime) + + # Relationships + bet: Mapped["Bet"] = relationship(back_populates="proposals") diff --git a/backend/data/app/models/sport_event.py b/backend/data/app/models/sport_event.py new file mode 100644 index 0000000..6406ece --- /dev/null +++ b/backend/data/app/models/sport_event.py @@ -0,0 +1,67 @@ +from sqlalchemy import ForeignKey, String, DateTime, Enum, Integer, Float +from sqlalchemy.orm import Mapped, mapped_column, relationship +from datetime import datetime +import enum +from app.database import Base + + +class SportType(enum.Enum): + FOOTBALL = "football" + BASKETBALL = "basketball" + BASEBALL = "baseball" + HOCKEY = "hockey" + SOCCER = "soccer" + + +class EventStatus(enum.Enum): + UPCOMING = "upcoming" + LIVE = "live" + COMPLETED = "completed" + CANCELLED = "cancelled" + + +class SportEvent(Base): + """Admin-created sporting events for spread betting""" + __tablename__ = "sport_events" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + + # Event details + sport: Mapped[SportType] = mapped_column(Enum(SportType)) + home_team: Mapped[str] = mapped_column(String(100)) + away_team: Mapped[str] = mapped_column(String(100)) + + # Spread (positive = home team favored, negative = away team favored) + # Example: +3 means home team is 3-point favorite + official_spread: Mapped[float] = mapped_column(Float) + + # Game info + game_time: Mapped[datetime] = mapped_column(DateTime) + venue: Mapped[str | None] = mapped_column(String(200), nullable=True) + league: Mapped[str | None] = mapped_column(String(100), nullable=True) + + # Status + status: Mapped[EventStatus] = mapped_column(Enum(EventStatus), default=EventStatus.UPCOMING) + + # Final scores (set when completed) + final_score_home: Mapped[int | None] = mapped_column(Integer, nullable=True) + final_score_away: Mapped[int | None] = mapped_column(Integer, nullable=True) + + # Spread range available for betting + min_spread: Mapped[float] = mapped_column(Float, default=-10.0) + max_spread: Mapped[float] = mapped_column(Float, default=10.0) + + # Betting limits + min_bet_amount: Mapped[float] = mapped_column(Float, default=10.0) + max_bet_amount: Mapped[float] = mapped_column(Float, default=1000.0) + + # Admin who created this event + created_by: Mapped[int] = mapped_column(ForeignKey("users.id")) + + # Timestamps + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + creator: Mapped["User"] = relationship(foreign_keys=[created_by]) + spread_bets: Mapped[list["SpreadBet"]] = relationship(back_populates="event") diff --git a/backend/data/app/models/spread_bet.py b/backend/data/app/models/spread_bet.py new file mode 100644 index 0000000..65bb3b5 --- /dev/null +++ b/backend/data/app/models/spread_bet.py @@ -0,0 +1,68 @@ +from sqlalchemy import ForeignKey, Numeric, String, DateTime, Enum, Float +from sqlalchemy.orm import Mapped, mapped_column, relationship +from datetime import datetime +from decimal import Decimal +import enum +from app.database import Base + + +class SpreadBetStatus(enum.Enum): + OPEN = "open" # Created, waiting for taker + MATCHED = "matched" # Taker found, both funds locked + COMPLETED = "completed" # Game finished, winner paid + CANCELLED = "cancelled" # Creator cancelled before match + DISPUTED = "disputed" # Settlement disputed + + +class TeamSide(enum.Enum): + HOME = "home" + AWAY = "away" + + +class SpreadBet(Base): + """Spread bets on sporting events""" + __tablename__ = "spread_bets" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + + # Event this bet is for + event_id: Mapped[int] = mapped_column(ForeignKey("sport_events.id")) + + # Spread value (e.g., -3, +3, 0) + # Positive = team is underdog, negative = team is favorite + spread: Mapped[float] = mapped_column(Float) + + # Which team (home or away) + team: Mapped[TeamSide] = mapped_column(Enum(TeamSide)) + + # Users + creator_id: Mapped[int] = mapped_column(ForeignKey("users.id")) + taker_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) + + # Stake amount (both users stake this amount) + stake_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2)) + + # House commission (default 10%, adjustable by admin) + house_commission_percent: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=Decimal("10.00")) + + # Status + status: Mapped[SpreadBetStatus] = mapped_column(Enum(SpreadBetStatus), default=SpreadBetStatus.OPEN) + + # Result + winner_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) + settled_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + + # Payout amounts (calculated after settlement) + payout_to_winner: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True) + house_fee_collected: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True) + + # Timestamps + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + matched_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + event: Mapped["SportEvent"] = relationship(back_populates="spread_bets") + creator: Mapped["User"] = relationship(foreign_keys=[creator_id], back_populates="created_spread_bets") + taker: Mapped["User | None"] = relationship(foreign_keys=[taker_id], back_populates="taken_spread_bets") + winner: Mapped["User | None"] = relationship(foreign_keys=[winner_id]) diff --git a/backend/data/app/models/transaction.py b/backend/data/app/models/transaction.py new file mode 100644 index 0000000..13f623a --- /dev/null +++ b/backend/data/app/models/transaction.py @@ -0,0 +1,42 @@ +from sqlalchemy import ForeignKey, Numeric, String, DateTime, Enum, Integer +from sqlalchemy.orm import Mapped, mapped_column, relationship +from datetime import datetime +from decimal import Decimal +import enum +from app.database import Base + + +class TransactionType(enum.Enum): + DEPOSIT = "deposit" + WITHDRAWAL = "withdrawal" + BET_PLACED = "bet_placed" + BET_WON = "bet_won" + BET_LOST = "bet_lost" + BET_CANCELLED = "bet_cancelled" + ESCROW_LOCK = "escrow_lock" + ESCROW_RELEASE = "escrow_release" + + +class TransactionStatus(enum.Enum): + PENDING = "pending" + COMPLETED = "completed" + FAILED = "failed" + + +class Transaction(Base): + __tablename__ = "transactions" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id")) + wallet_id: Mapped[int] = mapped_column(ForeignKey("wallets.id")) + type: Mapped[TransactionType] = mapped_column(Enum(TransactionType)) + amount: Mapped[Decimal] = mapped_column(Numeric(12, 2)) + balance_after: Mapped[Decimal] = mapped_column(Numeric(12, 2)) + reference_id: Mapped[int | None] = mapped_column(Integer, nullable=True) + description: Mapped[str] = mapped_column(String(500)) + status: Mapped[TransactionStatus] = mapped_column(Enum(TransactionStatus), default=TransactionStatus.COMPLETED) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + # Relationships + user: Mapped["User"] = relationship(back_populates="transactions") + wallet: Mapped["Wallet"] = relationship(back_populates="transactions") diff --git a/backend/data/app/models/user.py b/backend/data/app/models/user.py new file mode 100644 index 0000000..02a02ea --- /dev/null +++ b/backend/data/app/models/user.py @@ -0,0 +1,44 @@ +from sqlalchemy import String, DateTime, Enum, Float, Integer +from sqlalchemy.orm import Mapped, mapped_column, relationship +from datetime import datetime +import enum +from app.database import Base + + +class UserStatus(enum.Enum): + ACTIVE = "active" + SUSPENDED = "suspended" + PENDING_VERIFICATION = "pending_verification" + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + email: Mapped[str] = mapped_column(String(255), unique=True, index=True) + username: Mapped[str] = mapped_column(String(50), unique=True, index=True) + password_hash: Mapped[str] = mapped_column(String(255)) + + # Profile fields + display_name: Mapped[str | None] = mapped_column(String(100), nullable=True) + avatar_url: Mapped[str | None] = mapped_column(String(500), nullable=True) + bio: Mapped[str | None] = mapped_column(String(500), nullable=True) + + # Stats + total_bets: Mapped[int] = mapped_column(Integer, default=0) + wins: Mapped[int] = mapped_column(Integer, default=0) + losses: Mapped[int] = mapped_column(Integer, default=0) + win_rate: Mapped[float] = mapped_column(Float, default=0.0) + + is_admin: Mapped[bool] = mapped_column(default=False) + status: Mapped[UserStatus] = mapped_column(Enum(UserStatus), default=UserStatus.ACTIVE) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + wallet: Mapped["Wallet"] = relationship(back_populates="user", uselist=False) + created_bets: Mapped[list["Bet"]] = relationship(back_populates="creator", foreign_keys="Bet.creator_id") + accepted_bets: Mapped[list["Bet"]] = relationship(back_populates="opponent", foreign_keys="Bet.opponent_id") + transactions: Mapped[list["Transaction"]] = relationship(back_populates="user") + created_spread_bets: Mapped[list["SpreadBet"]] = relationship(back_populates="creator", foreign_keys="SpreadBet.creator_id") + taken_spread_bets: Mapped[list["SpreadBet"]] = relationship(back_populates="taker", foreign_keys="SpreadBet.taker_id") diff --git a/backend/data/app/models/wallet.py b/backend/data/app/models/wallet.py new file mode 100644 index 0000000..17cd52d --- /dev/null +++ b/backend/data/app/models/wallet.py @@ -0,0 +1,21 @@ +from sqlalchemy import ForeignKey, Numeric, String, DateTime +from sqlalchemy.orm import Mapped, mapped_column, relationship +from datetime import datetime +from decimal import Decimal +from app.database import Base + + +class Wallet(Base): + __tablename__ = "wallets" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), unique=True) + balance: Mapped[Decimal] = mapped_column(Numeric(12, 2), default=Decimal("0.00")) + escrow: Mapped[Decimal] = mapped_column(Numeric(12, 2), default=Decimal("0.00")) + currency: Mapped[str] = mapped_column(String(3), default="USD") + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + user: Mapped["User"] = relationship(back_populates="wallet") + transactions: Mapped[list["Transaction"]] = relationship(back_populates="wallet") diff --git a/backend/data/app/routers/__init__.py b/backend/data/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/data/app/routers/admin.py b/backend/data/app/routers/admin.py new file mode 100644 index 0000000..bb2fc19 --- /dev/null +++ b/backend/data/app/routers/admin.py @@ -0,0 +1,241 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, update +from typing import List +from datetime import datetime +from decimal import Decimal + +from app.database import get_db +from app.models import User, SportEvent, SpreadBet, AdminSettings, EventStatus +from app.schemas.sport_event import SportEventCreate, SportEventUpdate, SportEvent as SportEventSchema +from app.routers.auth import get_current_user + +router = APIRouter(prefix="/api/v1/admin", tags=["admin"]) + + +# Dependency to check if user is admin +async def get_admin_user(current_user: User = Depends(get_current_user)) -> User: + if not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin access required" + ) + return current_user + + +# Admin Settings Routes + +@router.get("/settings") +async def get_admin_settings( + db: AsyncSession = Depends(get_db), + admin: User = Depends(get_admin_user) +): + """Get current admin settings""" + result = await db.execute(select(AdminSettings).limit(1)) + settings = result.scalar_one_or_none() + + if not settings: + # Create default settings if none exist + settings = AdminSettings() + db.add(settings) + await db.commit() + await db.refresh(settings) + + return settings + + +@router.patch("/settings") +async def update_admin_settings( + default_house_commission_percent: Decimal | None = None, + default_min_bet_amount: Decimal | None = None, + default_max_bet_amount: Decimal | None = None, + default_min_spread: float | None = None, + default_max_spread: float | None = None, + spread_increment: float | None = None, + platform_name: str | None = None, + maintenance_mode: bool | None = None, + db: AsyncSession = Depends(get_db), + admin: User = Depends(get_admin_user) +): + """Update admin settings""" + result = await db.execute(select(AdminSettings).limit(1)) + settings = result.scalar_one_or_none() + + if not settings: + settings = AdminSettings() + db.add(settings) + + # Update provided fields + if default_house_commission_percent is not None: + settings.default_house_commission_percent = default_house_commission_percent + if default_min_bet_amount is not None: + settings.default_min_bet_amount = default_min_bet_amount + if default_max_bet_amount is not None: + settings.default_max_bet_amount = default_max_bet_amount + if default_min_spread is not None: + settings.default_min_spread = default_min_spread + if default_max_spread is not None: + settings.default_max_spread = default_max_spread + if spread_increment is not None: + settings.spread_increment = spread_increment + if platform_name is not None: + settings.platform_name = platform_name + if maintenance_mode is not None: + settings.maintenance_mode = maintenance_mode + + await db.commit() + await db.refresh(settings) + + return settings + + +# Sport Event Management Routes + +@router.post("/events", response_model=SportEventSchema) +async def create_sport_event( + event: SportEventCreate, + db: AsyncSession = Depends(get_db), + admin: User = Depends(get_admin_user) +): + """Create a new sport event""" + # Get default settings + result = await db.execute(select(AdminSettings).limit(1)) + settings = result.scalar_one_or_none() + + # Apply defaults from settings if not provided + if settings: + event_data = event.model_dump() + event_data['created_by'] = admin.id + new_event = SportEvent(**event_data) + else: + new_event = SportEvent(**event.model_dump(), created_by=admin.id) + + db.add(new_event) + await db.commit() + await db.refresh(new_event) + + return new_event + + +@router.get("/events", response_model=List[SportEventSchema]) +async def get_all_sport_events( + skip: int = 0, + limit: int = 100, + status: EventStatus | None = None, + db: AsyncSession = Depends(get_db), + admin: User = Depends(get_admin_user) +): + """Get all sport events (admin view)""" + query = select(SportEvent) + + if status: + query = query.where(SportEvent.status == status) + + query = query.offset(skip).limit(limit).order_by(SportEvent.game_time.desc()) + + result = await db.execute(query) + events = result.scalars().all() + + return events + + +@router.get("/events/{event_id}", response_model=SportEventSchema) +async def get_sport_event( + event_id: int, + db: AsyncSession = Depends(get_db), + admin: User = Depends(get_admin_user) +): + """Get a specific sport event""" + result = await db.execute(select(SportEvent).where(SportEvent.id == event_id)) + event = result.scalar_one_or_none() + + if not event: + raise HTTPException(status_code=404, detail="Event not found") + + return event + + +@router.patch("/events/{event_id}", response_model=SportEventSchema) +async def update_sport_event( + event_id: int, + event_update: SportEventUpdate, + db: AsyncSession = Depends(get_db), + admin: User = Depends(get_admin_user) +): + """Update a sport event""" + result = await db.execute(select(SportEvent).where(SportEvent.id == event_id)) + event = result.scalar_one_or_none() + + if not event: + raise HTTPException(status_code=404, detail="Event not found") + + # Update fields + update_data = event_update.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(event, field, value) + + await db.commit() + await db.refresh(event) + + return event + + +@router.delete("/events/{event_id}") +async def delete_sport_event( + event_id: int, + db: AsyncSession = Depends(get_db), + admin: User = Depends(get_admin_user) +): + """Delete a sport event (only if no active bets)""" + result = await db.execute(select(SportEvent).where(SportEvent.id == event_id)) + event = result.scalar_one_or_none() + + if not event: + raise HTTPException(status_code=404, detail="Event not found") + + # Check for active bets + bets_result = await db.execute( + select(SpreadBet).where( + SpreadBet.event_id == event_id, + SpreadBet.status.in_(['open', 'matched']) + ) + ) + active_bets = bets_result.scalars().all() + + if active_bets: + raise HTTPException( + status_code=400, + detail=f"Cannot delete event with {len(active_bets)} active bet(s)" + ) + + await db.delete(event) + await db.commit() + + return {"message": "Event deleted successfully"} + + +@router.post("/events/{event_id}/complete") +async def complete_sport_event( + event_id: int, + final_score_home: int, + final_score_away: int, + db: AsyncSession = Depends(get_db), + admin: User = Depends(get_admin_user) +): + """Mark event as completed and set final scores""" + result = await db.execute(select(SportEvent).where(SportEvent.id == event_id)) + event = result.scalar_one_or_none() + + if not event: + raise HTTPException(status_code=404, detail="Event not found") + + event.status = EventStatus.COMPLETED + event.final_score_home = final_score_home + event.final_score_away = final_score_away + + await db.commit() + await db.refresh(event) + + # TODO: Auto-settle all matched bets for this event + + return {"message": "Event completed", "event": event} diff --git a/backend/data/app/routers/auth.py b/backend/data/app/routers/auth.py new file mode 100644 index 0000000..7784a40 --- /dev/null +++ b/backend/data/app/routers/auth.py @@ -0,0 +1,82 @@ +from fastapi import APIRouter, Depends, status +from fastapi.security import OAuth2PasswordBearer +from sqlalchemy.ext.asyncio import AsyncSession +from jose import JWTError +from app.database import get_db +from app.schemas.user import UserCreate, UserLogin, TokenResponse, UserResponse +from app.services.auth_service import register_user, login_user +from app.crud.user import get_user_by_id +from app.utils.security import decode_token +from app.utils.exceptions import UnauthorizedError + +router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login") + + +async def get_current_user( + token: str = Depends(oauth2_scheme), + db: AsyncSession = Depends(get_db) +): + try: + payload = decode_token(token) + user_id: str = payload.get("sub") + if user_id is None: + raise UnauthorizedError() + except JWTError: + raise UnauthorizedError() + + user = await get_user_by_id(db, int(user_id)) + if user is None: + raise UnauthorizedError() + + return user + + +@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED) +async def register( + user_data: UserCreate, + db: AsyncSession = Depends(get_db) +): + return await register_user(db, user_data) + + +@router.post("/login", response_model=TokenResponse) +async def login( + login_data: UserLogin, + db: AsyncSession = Depends(get_db) +): + return await login_user(db, login_data) + + +@router.get("/me", response_model=UserResponse) +async def get_current_user_info( + current_user = Depends(get_current_user) +): + return current_user + + +@router.post("/refresh", response_model=TokenResponse) +async def refresh_token( + token: str, + db: AsyncSession = Depends(get_db) +): + try: + payload = decode_token(token) + user_id: str = payload.get("sub") + if user_id is None: + raise UnauthorizedError() + except JWTError: + raise UnauthorizedError() + + user = await get_user_by_id(db, int(user_id)) + if user is None: + raise UnauthorizedError() + + from app.utils.security import create_access_token, create_refresh_token + access_token = create_access_token({"sub": str(user.id)}) + new_refresh_token = create_refresh_token({"sub": str(user.id)}) + + return TokenResponse( + access_token=access_token, + refresh_token=new_refresh_token, + ) diff --git a/backend/data/app/routers/bets.py b/backend/data/app/routers/bets.py new file mode 100644 index 0000000..7909537 --- /dev/null +++ b/backend/data/app/routers/bets.py @@ -0,0 +1,173 @@ +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession +from app.database import get_db +from app.schemas.bet import BetCreate, BetUpdate, BetResponse, BetDetailResponse, SettleBetRequest +from app.routers.auth import get_current_user +from app.crud.bet import get_bet_by_id, get_open_bets, get_user_bets, create_bet +from app.services.bet_service import accept_bet, settle_bet, cancel_bet +from app.models import User, BetCategory, BetStatus +from app.utils.exceptions import BetNotFoundError, NotBetParticipantError + +router = APIRouter(prefix="/api/v1/bets", tags=["bets"]) + + +@router.get("", response_model=list[BetResponse]) +async def list_bets( + skip: int = Query(0, ge=0), + limit: int = Query(20, ge=1, le=100), + category: BetCategory | None = None, + db: AsyncSession = Depends(get_db) +): + bets = await get_open_bets(db, skip=skip, limit=limit, category=category) + return bets + + +@router.post("", response_model=BetResponse) +async def create_new_bet( + bet_data: BetCreate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + bet = await create_bet(db, bet_data, current_user.id) + await db.commit() + bet = await get_bet_by_id(db, bet.id) + return bet + + +@router.get("/{bet_id}", response_model=BetDetailResponse) +async def get_bet( + bet_id: int, + db: AsyncSession = Depends(get_db) +): + bet = await get_bet_by_id(db, bet_id) + if not bet: + raise BetNotFoundError() + return bet + + +@router.put("/{bet_id}", response_model=BetResponse) +async def update_bet( + bet_id: int, + bet_data: BetUpdate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + bet = await get_bet_by_id(db, bet_id) + if not bet: + raise BetNotFoundError() + + if bet.creator_id != current_user.id: + raise NotBetParticipantError() + + if bet.status != BetStatus.OPEN: + raise ValueError("Cannot update non-open bet") + + # Update fields + if bet_data.title is not None: + bet.title = bet_data.title + if bet_data.description is not None: + bet.description = bet_data.description + if bet_data.event_date is not None: + bet.event_date = bet_data.event_date + if bet_data.creator_position is not None: + bet.creator_position = bet_data.creator_position + if bet_data.opponent_position is not None: + bet.opponent_position = bet_data.opponent_position + if bet_data.stake_amount is not None: + bet.stake_amount = bet_data.stake_amount + if bet_data.creator_odds is not None: + bet.creator_odds = bet_data.creator_odds + if bet_data.opponent_odds is not None: + bet.opponent_odds = bet_data.opponent_odds + if bet_data.expires_at is not None: + bet.expires_at = bet_data.expires_at + + await db.commit() + await db.refresh(bet) + return bet + + +@router.delete("/{bet_id}") +async def delete_bet( + bet_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + await cancel_bet(db, bet_id, current_user.id) + return {"message": "Bet cancelled successfully"} + + +@router.post("/{bet_id}/accept", response_model=BetResponse) +async def accept_bet_route( + bet_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + bet = await accept_bet(db, bet_id, current_user.id) + return bet + + +@router.post("/{bet_id}/settle", response_model=BetDetailResponse) +async def settle_bet_route( + bet_id: int, + settle_data: SettleBetRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + bet = await settle_bet(db, bet_id, settle_data.winner_id, current_user.id) + return bet + + +@router.get("/my/created", response_model=list[BetResponse]) +async def get_my_created_bets( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + from sqlalchemy import select + from sqlalchemy.orm import selectinload + from app.models import Bet + + result = await db.execute( + select(Bet) + .where(Bet.creator_id == current_user.id) + .options(selectinload(Bet.creator), selectinload(Bet.opponent)) + .order_by(Bet.created_at.desc()) + ) + return list(result.scalars().all()) + + +@router.get("/my/accepted", response_model=list[BetResponse]) +async def get_my_accepted_bets( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + from sqlalchemy import select + from sqlalchemy.orm import selectinload + from app.models import Bet + + result = await db.execute( + select(Bet) + .where(Bet.opponent_id == current_user.id) + .options(selectinload(Bet.creator), selectinload(Bet.opponent)) + .order_by(Bet.created_at.desc()) + ) + return list(result.scalars().all()) + + +@router.get("/my/active", response_model=list[BetResponse]) +async def get_my_active_bets( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + bets = await get_user_bets(db, current_user.id) + active_bets = [bet for bet in bets if bet.status in [BetStatus.MATCHED, BetStatus.IN_PROGRESS]] + return active_bets + + +@router.get("/my/history", response_model=list[BetDetailResponse]) +async def get_my_bet_history( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + bets = await get_user_bets(db, current_user.id, status=BetStatus.COMPLETED) + return bets diff --git a/backend/data/app/routers/sport_events.py b/backend/data/app/routers/sport_events.py new file mode 100644 index 0000000..e59496d --- /dev/null +++ b/backend/data/app/routers/sport_events.py @@ -0,0 +1,158 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, and_ +from typing import List +from datetime import datetime +from decimal import Decimal + +from app.database import get_db +from app.models import User, SportEvent, SpreadBet, AdminSettings, EventStatus, SpreadBetStatus, TeamSide +from app.schemas.sport_event import SportEvent as SportEventSchema, SportEventWithBets +from app.schemas.spread_bet import SpreadBet as SpreadBetSchema, SpreadBetCreate, SpreadBetTake, SpreadBetDetail +from app.routers.auth import get_current_user + +router = APIRouter(prefix="/api/v1/sport-events", tags=["sport-events"]) + + +def generate_spread_grid(min_spread: float, max_spread: float, increment: float = 0.5) -> List[float]: + """Generate list of spread values from min to max with given increment""" + spreads = [] + current = min_spread + while current <= max_spread: + spreads.append(round(current, 1)) + current += increment + return spreads + + +@router.get("", response_model=List[SportEventSchema]) +async def get_upcoming_events( + skip: int = 0, + limit: int = 20, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """Get upcoming sport events available for betting""" + query = ( + select(SportEvent) + .where( + and_( + SportEvent.status == EventStatus.UPCOMING, + SportEvent.game_time > datetime.utcnow() + ) + ) + .order_by(SportEvent.game_time.asc()) + .offset(skip) + .limit(limit) + ) + + result = await db.execute(query) + events = result.scalars().all() + + return events + + +@router.get("/{event_id}", response_model=SportEventWithBets) +async def get_event_with_spread_grid( + event_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """Get event with spread grid showing available/occupied bets""" + # Get event + result = await db.execute(select(SportEvent).where(SportEvent.id == event_id)) + event = result.scalar_one_or_none() + + if not event: + raise HTTPException(status_code=404, detail="Event not found") + + # Get admin settings for spread increment + settings_result = await db.execute(select(AdminSettings).limit(1)) + settings = settings_result.scalar_one_or_none() + spread_increment = settings.spread_increment if settings else 0.5 + + # Generate spread grid + spreads = generate_spread_grid(event.min_spread, event.max_spread, spread_increment) + + # Get all open and matched bets for this event + bets_result = await db.execute( + select(SpreadBet) + .where( + and_( + SpreadBet.event_id == event_id, + SpreadBet.status.in_([SpreadBetStatus.OPEN, SpreadBetStatus.MATCHED]) + ) + ) + ) + bets = bets_result.scalars().all() + + # Build spread grid + spread_grid = {} + for spread in spreads: + # Find bet at this spread (home team perspective) + bet_at_spread = next( + (b for b in bets if b.spread == spread and b.team == TeamSide.HOME), + None + ) + # Also check negative spread for away team + opposite_spread = -spread + bet_at_opposite = next( + (b for b in bets if b.spread == opposite_spread and b.team == TeamSide.AWAY), + None + ) + + if bet_at_spread: + # Get creator info + creator_result = await db.execute(select(User).where(User.id == bet_at_spread.creator_id)) + creator = creator_result.scalar_one() + + spread_grid[str(spread)] = { + "bet_id": bet_at_spread.id, + "creator_id": bet_at_spread.creator_id, + "creator_username": creator.username, + "stake": float(bet_at_spread.stake_amount), + "status": bet_at_spread.status.value, + "team": bet_at_spread.team.value, + "can_take": bet_at_spread.status == SpreadBetStatus.OPEN and bet_at_spread.creator_id != current_user.id + } + elif bet_at_opposite: + # There's a bet on the opposite side + creator_result = await db.execute(select(User).where(User.id == bet_at_opposite.creator_id)) + creator = creator_result.scalar_one() + + spread_grid[str(spread)] = { + "bet_id": bet_at_opposite.id, + "creator_id": bet_at_opposite.creator_id, + "creator_username": creator.username, + "stake": float(bet_at_opposite.stake_amount), + "status": bet_at_opposite.status.value, + "team": bet_at_opposite.team.value, + "can_take": bet_at_opposite.status == SpreadBetStatus.OPEN and bet_at_opposite.creator_id != current_user.id + } + else: + # No bet at this spread + spread_grid[str(spread)] = None + + # Convert event to dict and add spread_grid + event_dict = { + "id": event.id, + "sport": event.sport, + "home_team": event.home_team, + "away_team": event.away_team, + "official_spread": event.official_spread, + "game_time": event.game_time, + "venue": event.venue, + "league": event.league, + "min_spread": event.min_spread, + "max_spread": event.max_spread, + "min_bet_amount": event.min_bet_amount, + "max_bet_amount": event.max_bet_amount, + "status": event.status, + "final_score_home": event.final_score_home, + "final_score_away": event.final_score_away, + "created_by": event.created_by, + "created_at": event.created_at, + "updated_at": event.updated_at, + "spread_grid": spread_grid + } + + return event_dict diff --git a/backend/data/app/routers/spread_bets.py b/backend/data/app/routers/spread_bets.py new file mode 100644 index 0000000..638c029 --- /dev/null +++ b/backend/data/app/routers/spread_bets.py @@ -0,0 +1,263 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, and_ +from typing import List +from datetime import datetime +from decimal import Decimal + +from app.database import get_db +from app.models import User, SportEvent, SpreadBet, Wallet, Transaction, AdminSettings +from app.models import EventStatus, SpreadBetStatus, TeamSide, TransactionType, TransactionStatus +from app.schemas.spread_bet import SpreadBet as SpreadBetSchema, SpreadBetCreate, SpreadBetDetail +from app.routers.auth import get_current_user + +router = APIRouter(prefix="/api/v1/spread-bets", tags=["spread-bets"]) + + +@router.post("", response_model=SpreadBetSchema) +async def create_spread_bet( + bet_data: SpreadBetCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """Create a new spread bet on an event""" + # Get event + result = await db.execute(select(SportEvent).where(SportEvent.id == bet_data.event_id)) + event = result.scalar_one_or_none() + + if not event: + raise HTTPException(status_code=404, detail="Event not found") + + if event.status != EventStatus.UPCOMING: + raise HTTPException(status_code=400, detail="Event is not available for betting") + + if event.game_time <= datetime.utcnow(): + raise HTTPException(status_code=400, detail="Event has already started") + + # Validate spread is within range + if bet_data.spread < event.min_spread or bet_data.spread > event.max_spread: + raise HTTPException( + status_code=400, + detail=f"Spread must be between {event.min_spread} and {event.max_spread}" + ) + + # Validate stake amount + if bet_data.stake_amount < Decimal(str(event.min_bet_amount)): + raise HTTPException( + status_code=400, + detail=f"Minimum bet amount is ${event.min_bet_amount}" + ) + + if bet_data.stake_amount > Decimal(str(event.max_bet_amount)): + raise HTTPException( + status_code=400, + detail=f"Maximum bet amount is ${event.max_bet_amount}" + ) + + # Check if bet already exists at this spread for this team + existing_bet_result = await db.execute( + select(SpreadBet).where( + and_( + SpreadBet.event_id == bet_data.event_id, + SpreadBet.spread == bet_data.spread, + SpreadBet.team == bet_data.team, + SpreadBet.status == SpreadBetStatus.OPEN + ) + ) + ) + existing_bet = existing_bet_result.scalar_one_or_none() + + if existing_bet: + raise HTTPException( + status_code=400, + detail="A bet already exists at this spread. First come, first served!" + ) + + # Check user wallet balance + wallet_result = await db.execute(select(Wallet).where(Wallet.user_id == current_user.id)) + wallet = wallet_result.scalar_one_or_none() + + if not wallet or wallet.balance < bet_data.stake_amount: + raise HTTPException(status_code=400, detail="Insufficient wallet balance") + + # Get default commission from settings + settings_result = await db.execute(select(AdminSettings).limit(1)) + settings = settings_result.scalar_one_or_none() + house_commission = settings.default_house_commission_percent if settings else Decimal("10.00") + + # Create bet (no funds locked until matched) + new_bet = SpreadBet( + event_id=bet_data.event_id, + spread=bet_data.spread, + team=bet_data.team, + creator_id=current_user.id, + stake_amount=bet_data.stake_amount, + house_commission_percent=house_commission, + status=SpreadBetStatus.OPEN + ) + + db.add(new_bet) + await db.commit() + await db.refresh(new_bet) + + return new_bet + + +@router.post("/{bet_id}/take", response_model=SpreadBetSchema) +async def take_spread_bet( + bet_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """Take an open spread bet (auto-assigns opposite side)""" + # Get bet with lock + result = await db.execute( + select(SpreadBet).where(SpreadBet.id == bet_id).with_for_update() + ) + bet = result.scalar_one_or_none() + + if not bet: + raise HTTPException(status_code=404, detail="Bet not found") + + if bet.status != SpreadBetStatus.OPEN: + raise HTTPException(status_code=400, detail="Bet is no longer available") + + if bet.creator_id == current_user.id: + raise HTTPException(status_code=400, detail="Cannot take your own bet") + + # Get event + event_result = await db.execute(select(SportEvent).where(SportEvent.id == bet.event_id)) + event = event_result.scalar_one_or_none() + + if event.game_time <= datetime.utcnow(): + raise HTTPException(status_code=400, detail="Event has already started") + + # Check taker's wallet balance + taker_wallet_result = await db.execute(select(Wallet).where(Wallet.user_id == current_user.id)) + taker_wallet = taker_wallet_result.scalar_one_or_none() + + if not taker_wallet or taker_wallet.balance < bet.stake_amount: + raise HTTPException(status_code=400, detail="Insufficient wallet balance") + + # Get creator's wallet + creator_wallet_result = await db.execute(select(Wallet).where(Wallet.user_id == bet.creator_id)) + creator_wallet = creator_wallet_result.scalar_one_or_none() + + if not creator_wallet or creator_wallet.balance < bet.stake_amount: + raise HTTPException(status_code=400, detail="Creator no longer has sufficient funds") + + # Lock funds from both users + async with db.begin_nested(): + # Lock creator funds + creator_wallet.balance -= bet.stake_amount + creator_wallet.escrow += bet.stake_amount + + # Lock taker funds + taker_wallet.balance -= bet.stake_amount + taker_wallet.escrow += bet.stake_amount + + # Update bet + bet.taker_id = current_user.id + bet.status = SpreadBetStatus.MATCHED + bet.matched_at = datetime.utcnow() + + # Create escrow transactions + creator_tx = Transaction( + wallet_id=creator_wallet.id, + user_id=bet.creator_id, + amount=-bet.stake_amount, + balance_after=creator_wallet.balance, + transaction_type=TransactionType.ESCROW_LOCK, + status=TransactionStatus.COMPLETED, + description=f"Escrow locked for spread bet #{bet.id}" + ) + db.add(creator_tx) + + taker_tx = Transaction( + wallet_id=taker_wallet.id, + user_id=current_user.id, + amount=-bet.stake_amount, + balance_after=taker_wallet.balance, + transaction_type=TransactionType.ESCROW_LOCK, + status=TransactionStatus.COMPLETED, + description=f"Escrow locked for spread bet #{bet.id}" + ) + db.add(taker_tx) + + await db.commit() + await db.refresh(bet) + + return bet + + +@router.get("/my-active", response_model=List[SpreadBetDetail]) +async def get_my_active_spread_bets( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """Get current user's active spread bets""" + result = await db.execute( + select(SpreadBet).where( + and_( + ((SpreadBet.creator_id == current_user.id) | (SpreadBet.taker_id == current_user.id)), + SpreadBet.status.in_([SpreadBetStatus.OPEN, SpreadBetStatus.MATCHED]) + ) + ).order_by(SpreadBet.created_at.desc()) + ) + bets = result.scalars().all() + + # Enrich with details + detailed_bets = [] + for bet in bets: + # Get creator + creator_result = await db.execute(select(User).where(User.id == bet.creator_id)) + creator = creator_result.scalar_one() + + # Get taker if exists + taker_username = None + if bet.taker_id: + taker_result = await db.execute(select(User).where(User.id == bet.taker_id)) + taker = taker_result.scalar_one() + taker_username = taker.username + + # Get event + event_result = await db.execute(select(SportEvent).where(SportEvent.id == bet.event_id)) + event = event_result.scalar_one() + + bet_detail = { + **bet.__dict__, + "creator_username": creator.username, + "taker_username": taker_username, + "event_home_team": event.home_team, + "event_away_team": event.away_team, + "event_official_spread": event.official_spread, + "event_game_time": event.game_time + } + detailed_bets.append(bet_detail) + + return detailed_bets + + +@router.delete("/{bet_id}") +async def cancel_spread_bet( + bet_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """Cancel an open spread bet (only creator, only if not matched)""" + result = await db.execute(select(SpreadBet).where(SpreadBet.id == bet_id)) + bet = result.scalar_one_or_none() + + if not bet: + raise HTTPException(status_code=404, detail="Bet not found") + + if bet.creator_id != current_user.id: + raise HTTPException(status_code=403, detail="Only the creator can cancel this bet") + + if bet.status != SpreadBetStatus.OPEN: + raise HTTPException(status_code=400, detail="Can only cancel open bets") + + bet.status = SpreadBetStatus.CANCELLED + await db.commit() + + return {"message": "Bet cancelled successfully"} diff --git a/backend/data/app/routers/users.py b/backend/data/app/routers/users.py new file mode 100644 index 0000000..7e8687f --- /dev/null +++ b/backend/data/app/routers/users.py @@ -0,0 +1,62 @@ +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession +from app.database import get_db +from app.schemas.user import UserResponse, UserUpdate, UserStats +from app.routers.auth import get_current_user +from app.crud.user import get_user_by_id +from app.crud.bet import get_user_bets +from app.models import User, BetStatus +from app.utils.exceptions import UserNotFoundError + +router = APIRouter(prefix="/api/v1/users", tags=["users"]) + + +@router.get("/{user_id}", response_model=UserResponse) +async def get_user( + user_id: int, + db: AsyncSession = Depends(get_db) +): + user = await get_user_by_id(db, user_id) + if not user: + raise UserNotFoundError() + return user + + +@router.put("/me", response_model=UserResponse) +async def update_current_user( + user_data: UserUpdate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + if user_data.display_name is not None: + current_user.display_name = user_data.display_name + if user_data.avatar_url is not None: + current_user.avatar_url = user_data.avatar_url + if user_data.bio is not None: + current_user.bio = user_data.bio + + await db.commit() + await db.refresh(current_user) + return current_user + + +@router.get("/{user_id}/stats", response_model=UserStats) +async def get_user_stats( + user_id: int, + db: AsyncSession = Depends(get_db) +): + user = await get_user_by_id(db, user_id) + if not user: + raise UserNotFoundError() + + # Get active bets count + user_bets = await get_user_bets(db, user_id) + active_bets = sum(1 for bet in user_bets if bet.status in [BetStatus.MATCHED, BetStatus.IN_PROGRESS]) + + return UserStats( + total_bets=user.total_bets, + wins=user.wins, + losses=user.losses, + win_rate=user.win_rate, + active_bets=active_bets, + ) diff --git a/backend/data/app/routers/wallet.py b/backend/data/app/routers/wallet.py new file mode 100644 index 0000000..b3e4a55 --- /dev/null +++ b/backend/data/app/routers/wallet.py @@ -0,0 +1,50 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession +from app.database import get_db +from app.schemas.wallet import WalletResponse, DepositRequest, WithdrawalRequest, TransactionResponse +from app.routers.auth import get_current_user +from app.crud.wallet import get_user_wallet, get_wallet_transactions +from app.services.wallet_service import deposit_funds, withdraw_funds +from app.models import User + +router = APIRouter(prefix="/api/v1/wallet", tags=["wallet"]) + + +@router.get("", response_model=WalletResponse) +async def get_wallet( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + wallet = await get_user_wallet(db, current_user.id) + return wallet + + +@router.post("/deposit", response_model=WalletResponse) +async def deposit( + deposit_data: DepositRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + wallet = await deposit_funds(db, current_user.id, deposit_data.amount) + return wallet + + +@router.post("/withdraw", response_model=WalletResponse) +async def withdraw( + withdrawal_data: WithdrawalRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + wallet = await withdraw_funds(db, current_user.id, withdrawal_data.amount) + return wallet + + +@router.get("/transactions", response_model=list[TransactionResponse]) +async def get_transactions( + limit: int = 50, + offset: int = 0, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + transactions = await get_wallet_transactions(db, current_user.id, limit, offset) + return transactions diff --git a/backend/data/app/routers/websocket.py b/backend/data/app/routers/websocket.py new file mode 100644 index 0000000..ba9c385 --- /dev/null +++ b/backend/data/app/routers/websocket.py @@ -0,0 +1,43 @@ +from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query +from typing import Dict +import json + +router = APIRouter(tags=["websocket"]) + +# Store active connections +active_connections: Dict[int, WebSocket] = {} + + +@router.websocket("/api/v1/ws") +async def websocket_endpoint(websocket: WebSocket, token: str = Query(...)): + await websocket.accept() + + # In a real implementation, you would validate the token here + # For MVP, we'll accept all connections + user_id = 1 # Placeholder + + active_connections[user_id] = websocket + + try: + while True: + data = await websocket.receive_text() + # Handle incoming messages if needed + except WebSocketDisconnect: + if user_id in active_connections: + del active_connections[user_id] + + +async def broadcast_event(event_type: str, data: dict, user_ids: list[int] = None): + """Broadcast an event to specific users or all connected users""" + message = json.dumps({ + "type": event_type, + "data": data + }) + + if user_ids: + for user_id in user_ids: + if user_id in active_connections: + await active_connections[user_id].send_text(message) + else: + for connection in active_connections.values(): + await connection.send_text(message) diff --git a/backend/data/app/schemas/__init__.py b/backend/data/app/schemas/__init__.py new file mode 100644 index 0000000..0f367bf --- /dev/null +++ b/backend/data/app/schemas/__init__.py @@ -0,0 +1,47 @@ +from app.schemas.user import ( + UserCreate, + UserLogin, + UserUpdate, + UserSummary, + UserResponse, + UserStats, + TokenResponse, + TokenData, +) +from app.schemas.wallet import ( + WalletResponse, + DepositRequest, + WithdrawalRequest, + TransactionResponse, +) +from app.schemas.bet import ( + BetCreate, + BetUpdate, + BetResponse, + BetDetailResponse, + SettleBetRequest, + ProposalCreate, + ProposalResponse, +) + +__all__ = [ + "UserCreate", + "UserLogin", + "UserUpdate", + "UserSummary", + "UserResponse", + "UserStats", + "TokenResponse", + "TokenData", + "WalletResponse", + "DepositRequest", + "WithdrawalRequest", + "TransactionResponse", + "BetCreate", + "BetUpdate", + "BetResponse", + "BetDetailResponse", + "SettleBetRequest", + "ProposalCreate", + "ProposalResponse", +] diff --git a/backend/data/app/schemas/bet.py b/backend/data/app/schemas/bet.py new file mode 100644 index 0000000..f870258 --- /dev/null +++ b/backend/data/app/schemas/bet.py @@ -0,0 +1,89 @@ +from pydantic import BaseModel, Field, ConfigDict +from decimal import Decimal +from datetime import datetime +from app.models.bet import BetCategory, BetStatus, BetVisibility, ProposalStatus +from app.schemas.user import UserSummary + + +class BetCreate(BaseModel): + title: str = Field(..., min_length=5, max_length=200) + description: str = Field(..., max_length=2000) + category: BetCategory + event_name: str = Field(..., max_length=200) + event_date: datetime | None = None + creator_position: str = Field(..., max_length=500) + opponent_position: str = Field(..., max_length=500) + stake_amount: Decimal = Field(..., gt=0, le=10000) + creator_odds: float = Field(default=1.0, gt=0) + opponent_odds: float = Field(default=1.0, gt=0) + visibility: BetVisibility = BetVisibility.PUBLIC + expires_at: datetime | None = None + + +class BetUpdate(BaseModel): + title: str | None = Field(None, min_length=5, max_length=200) + description: str | None = Field(None, max_length=2000) + event_date: datetime | None = None + creator_position: str | None = Field(None, max_length=500) + opponent_position: str | None = Field(None, max_length=500) + stake_amount: Decimal | None = Field(None, gt=0, le=10000) + creator_odds: float | None = Field(None, gt=0) + opponent_odds: float | None = Field(None, gt=0) + expires_at: datetime | None = None + + +class BetResponse(BaseModel): + id: int + title: str + description: str + category: BetCategory + event_name: str + event_date: datetime | None + creator_position: str + opponent_position: str + creator_odds: float + opponent_odds: float + stake_amount: Decimal + currency: str + status: BetStatus + visibility: BetVisibility + creator: UserSummary + opponent: UserSummary | None + expires_at: datetime | None + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class BetDetailResponse(BetResponse): + winner_id: int | None + settled_at: datetime | None + settled_by: str | None + + +class SettleBetRequest(BaseModel): + winner_id: int + + +class ProposalCreate(BaseModel): + proposed_stake: Decimal = Field(..., gt=0, le=10000) + proposed_creator_odds: float = Field(..., gt=0) + proposed_opponent_odds: float = Field(..., gt=0) + message: str | None = Field(None, max_length=500) + expires_at: datetime + + +class ProposalResponse(BaseModel): + id: int + bet_id: int + proposer_id: int + proposed_stake: Decimal + proposed_creator_odds: float + proposed_opponent_odds: float + message: str | None + status: ProposalStatus + created_at: datetime + expires_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/data/app/schemas/sport_event.py b/backend/data/app/schemas/sport_event.py new file mode 100644 index 0000000..913c4b6 --- /dev/null +++ b/backend/data/app/schemas/sport_event.py @@ -0,0 +1,69 @@ +from pydantic import BaseModel, Field +from datetime import datetime +from decimal import Decimal +from app.models.sport_event import SportType, EventStatus + + +# Sport Event Schemas + +class SportEventBase(BaseModel): + sport: SportType + home_team: str = Field(..., min_length=1, max_length=100) + away_team: str = Field(..., min_length=1, max_length=100) + official_spread: float + game_time: datetime + venue: str | None = None + league: str | None = None + min_spread: float = -10.0 + max_spread: float = 10.0 + min_bet_amount: float = 10.0 + max_bet_amount: float = 1000.0 + + +class SportEventCreate(SportEventBase): + """Schema for admin creating a new sport event""" + pass + + +class SportEventUpdate(BaseModel): + """Schema for admin updating event details""" + sport: SportType | None = None + home_team: str | None = None + away_team: str | None = None + official_spread: float | None = None + game_time: datetime | None = None + venue: str | None = None + league: str | None = None + status: EventStatus | None = None + final_score_home: int | None = None + final_score_away: int | None = None + min_spread: float | None = None + max_spread: float | None = None + min_bet_amount: float | None = None + max_bet_amount: float | None = None + + +class SportEvent(SportEventBase): + """Schema for returning sport event""" + id: int + status: EventStatus + final_score_home: int | None + final_score_away: int | None + created_by: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class SportEventWithBets(SportEvent): + """Sport event with spread grid information""" + spread_grid: dict[str, dict] # spread value -> bet info + # Example: { + # "-3.0": {"bet_id": 5, "creator": "Alice", "stake": 100.00, "status": "open"}, + # "3.0": null, # No bet at this spread + # } + + class Config: + from_attributes = True diff --git a/backend/data/app/schemas/spread_bet.py b/backend/data/app/schemas/spread_bet.py new file mode 100644 index 0000000..c63a20d --- /dev/null +++ b/backend/data/app/schemas/spread_bet.py @@ -0,0 +1,60 @@ +from pydantic import BaseModel, Field +from datetime import datetime +from decimal import Decimal +from app.models.spread_bet import SpreadBetStatus, TeamSide + + +# Spread Bet Schemas + +class SpreadBetCreate(BaseModel): + """Schema for creating a spread bet""" + event_id: int + spread: float + team: TeamSide + stake_amount: Decimal = Field(..., gt=0, decimal_places=2) + + +class SpreadBetTake(BaseModel): + """Schema for taking a spread bet (no body needed, bet_id in URL)""" + pass + + +class SpreadBet(BaseModel): + """Schema for returning spread bet""" + id: int + event_id: int + spread: float + team: TeamSide + creator_id: int + taker_id: int | None + stake_amount: Decimal + house_commission_percent: Decimal + status: SpreadBetStatus + winner_id: int | None + settled_at: datetime | None + payout_to_winner: Decimal | None + house_fee_collected: Decimal | None + created_at: datetime + matched_at: datetime | None + updated_at: datetime + + class Config: + from_attributes = True + + +class SpreadBetDetail(SpreadBet): + """Detailed spread bet with creator/taker info""" + creator_username: str + taker_username: str | None + event_home_team: str + event_away_team: str + event_official_spread: float + event_game_time: datetime + + class Config: + from_attributes = True + + +class SpreadBetSettle(BaseModel): + """Schema for settling a spread bet""" + winner_id: int diff --git a/backend/data/app/schemas/user.py b/backend/data/app/schemas/user.py new file mode 100644 index 0000000..352f54a --- /dev/null +++ b/backend/data/app/schemas/user.py @@ -0,0 +1,67 @@ +from pydantic import BaseModel, EmailStr, Field, ConfigDict +from datetime import datetime +from app.models.user import UserStatus + + +class UserCreate(BaseModel): + email: EmailStr + username: str = Field(..., min_length=3, max_length=50) + password: str = Field(..., min_length=8) + display_name: str | None = None + + +class UserLogin(BaseModel): + email: EmailStr + password: str + + +class UserUpdate(BaseModel): + display_name: str | None = None + avatar_url: str | None = None + bio: str | None = None + + +class UserSummary(BaseModel): + id: int + username: str + display_name: str | None + avatar_url: str | None + + model_config = ConfigDict(from_attributes=True) + + +class UserResponse(BaseModel): + id: int + email: str + username: str + display_name: str | None + avatar_url: str | None + bio: str | None + total_bets: int + wins: int + losses: int + win_rate: float + status: UserStatus + created_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class UserStats(BaseModel): + total_bets: int + wins: int + losses: int + win_rate: float + active_bets: int + + model_config = ConfigDict(from_attributes=True) + + +class TokenResponse(BaseModel): + access_token: str + refresh_token: str + token_type: str = "bearer" + + +class TokenData(BaseModel): + user_id: int | None = None diff --git a/backend/data/app/schemas/wallet.py b/backend/data/app/schemas/wallet.py new file mode 100644 index 0000000..c58d8de --- /dev/null +++ b/backend/data/app/schemas/wallet.py @@ -0,0 +1,38 @@ +from pydantic import BaseModel, Field, ConfigDict +from decimal import Decimal +from datetime import datetime +from app.models.transaction import TransactionType, TransactionStatus + + +class WalletResponse(BaseModel): + id: int + user_id: int + balance: Decimal + escrow: Decimal + currency: str + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class DepositRequest(BaseModel): + amount: Decimal = Field(..., gt=0, le=10000) + + +class WithdrawalRequest(BaseModel): + amount: Decimal = Field(..., gt=0) + + +class TransactionResponse(BaseModel): + id: int + user_id: int + type: TransactionType + amount: Decimal + balance_after: Decimal + reference_id: int | None + description: str + status: TransactionStatus + created_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/data/app/services/__init__.py b/backend/data/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/data/app/services/auth_service.py b/backend/data/app/services/auth_service.py new file mode 100644 index 0000000..623ca9b --- /dev/null +++ b/backend/data/app/services/auth_service.py @@ -0,0 +1,50 @@ +from sqlalchemy.ext.asyncio import AsyncSession +from app.models import User +from app.schemas.user import UserCreate, UserLogin, TokenResponse +from app.crud.user import create_user, get_user_by_email, get_user_by_username +from app.utils.security import verify_password, create_access_token, create_refresh_token +from app.utils.exceptions import InvalidCredentialsError, UserAlreadyExistsError + + +async def register_user(db: AsyncSession, user_data: UserCreate) -> TokenResponse: + # Check if user already exists + existing_user = await get_user_by_email(db, user_data.email) + if existing_user: + raise UserAlreadyExistsError("Email already registered") + + existing_username = await get_user_by_username(db, user_data.username) + if existing_username: + raise UserAlreadyExistsError("Username already taken") + + # Create user + user = await create_user(db, user_data) + await db.commit() + + # Generate tokens + access_token = create_access_token({"sub": str(user.id)}) + refresh_token = create_refresh_token({"sub": str(user.id)}) + + return TokenResponse( + access_token=access_token, + refresh_token=refresh_token, + ) + + +async def login_user(db: AsyncSession, login_data: UserLogin) -> TokenResponse: + # Get user by email + user = await get_user_by_email(db, login_data.email) + if not user: + raise InvalidCredentialsError() + + # Verify password + if not verify_password(login_data.password, user.password_hash): + raise InvalidCredentialsError() + + # Generate tokens + access_token = create_access_token({"sub": str(user.id)}) + refresh_token = create_refresh_token({"sub": str(user.id)}) + + return TokenResponse( + access_token=access_token, + refresh_token=refresh_token, + ) diff --git a/backend/data/app/services/bet_service.py b/backend/data/app/services/bet_service.py new file mode 100644 index 0000000..be9bc1c --- /dev/null +++ b/backend/data/app/services/bet_service.py @@ -0,0 +1,178 @@ +from sqlalchemy.ext.asyncio import AsyncSession +from datetime import datetime +from app.models import Bet, BetStatus, TransactionType +from app.crud.bet import get_bet_by_id +from app.crud.wallet import get_user_wallet, create_transaction +from app.crud.user import update_user_stats +from app.utils.exceptions import ( + BetNotFoundError, + BetNotAvailableError, + CannotAcceptOwnBetError, + InsufficientFundsError, + NotBetParticipantError, +) + + +async def accept_bet(db: AsyncSession, bet_id: int, user_id: int) -> Bet: + from sqlalchemy.orm import selectinload + + # Use transaction for atomic operations + async with db.begin_nested(): + # Get and lock the bet + bet = await db.get(Bet, bet_id, with_for_update=True) + if not bet or bet.status != BetStatus.OPEN: + raise BetNotAvailableError() + + if bet.creator_id == user_id: + raise CannotAcceptOwnBetError() + + # Get user's wallet and verify funds + user_wallet = await get_user_wallet(db, user_id) + if not user_wallet or user_wallet.balance < bet.stake_amount: + raise InsufficientFundsError() + + # Get creator's wallet and lock their funds too + creator_wallet = await get_user_wallet(db, bet.creator_id) + if not creator_wallet or creator_wallet.balance < bet.stake_amount: + raise BetNotAvailableError() + + # Lock funds in escrow for both parties + user_wallet.balance -= bet.stake_amount + user_wallet.escrow += bet.stake_amount + + creator_wallet.balance -= bet.stake_amount + creator_wallet.escrow += bet.stake_amount + + # Update bet + bet.opponent_id = user_id + bet.status = BetStatus.MATCHED + + # Create transaction records + await create_transaction( + db=db, + user_id=user_id, + wallet_id=user_wallet.id, + transaction_type=TransactionType.ESCROW_LOCK, + amount=-bet.stake_amount, + balance_after=user_wallet.balance, + reference_id=bet.id, + description=f"Escrow for bet: {bet.title}", + ) + + await create_transaction( + db=db, + user_id=bet.creator_id, + wallet_id=creator_wallet.id, + transaction_type=TransactionType.ESCROW_LOCK, + amount=-bet.stake_amount, + balance_after=creator_wallet.balance, + reference_id=bet.id, + description=f"Escrow for bet: {bet.title}", + ) + + await db.commit() + + # Refresh and eagerly load relationships + from sqlalchemy import select + result = await db.execute( + select(Bet) + .where(Bet.id == bet_id) + .options(selectinload(Bet.creator), selectinload(Bet.opponent)) + ) + bet = result.scalar_one() + return bet + + +async def settle_bet( + db: AsyncSession, + bet_id: int, + winner_id: int, + settler_id: int +) -> Bet: + async with db.begin_nested(): + bet = await get_bet_by_id(db, bet_id) + if not bet: + raise BetNotFoundError() + + # Verify settler is a participant + if settler_id not in [bet.creator_id, bet.opponent_id]: + raise NotBetParticipantError() + + # Verify winner is a participant + if winner_id not in [bet.creator_id, bet.opponent_id]: + raise ValueError("Invalid winner") + + # Determine loser + loser_id = bet.opponent_id if winner_id == bet.creator_id else bet.creator_id + + # Get wallets + winner_wallet = await get_user_wallet(db, winner_id) + loser_wallet = await get_user_wallet(db, loser_id) + + if not winner_wallet or not loser_wallet: + raise ValueError("Wallet not found") + + # Calculate payout (winner gets both stakes) + total_payout = bet.stake_amount * 2 + + # Release escrow and distribute funds + winner_wallet.escrow -= bet.stake_amount + winner_wallet.balance += total_payout + + loser_wallet.escrow -= bet.stake_amount + + # Update bet + bet.winner_id = winner_id + bet.status = BetStatus.COMPLETED + bet.settled_at = datetime.utcnow() + bet.settled_by = "participant" + + # Create transaction records + await create_transaction( + db=db, + user_id=winner_id, + wallet_id=winner_wallet.id, + transaction_type=TransactionType.BET_WON, + amount=total_payout, + balance_after=winner_wallet.balance, + reference_id=bet.id, + description=f"Won bet: {bet.title}", + ) + + await create_transaction( + db=db, + user_id=loser_id, + wallet_id=loser_wallet.id, + transaction_type=TransactionType.BET_LOST, + amount=-bet.stake_amount, + balance_after=loser_wallet.balance, + reference_id=bet.id, + description=f"Lost bet: {bet.title}", + ) + + # Update user stats + await update_user_stats(db, winner_id, won=True) + await update_user_stats(db, loser_id, won=False) + + await db.commit() + await db.refresh(bet) + return bet + + +async def cancel_bet(db: AsyncSession, bet_id: int, user_id: int) -> Bet: + async with db.begin_nested(): + bet = await get_bet_by_id(db, bet_id) + if not bet: + raise BetNotFoundError() + + if bet.creator_id != user_id: + raise NotBetParticipantError() + + if bet.status != BetStatus.OPEN: + raise BetNotAvailableError() + + bet.status = BetStatus.CANCELLED + + await db.commit() + await db.refresh(bet) + return bet diff --git a/backend/data/app/services/wallet_service.py b/backend/data/app/services/wallet_service.py new file mode 100644 index 0000000..aec9f24 --- /dev/null +++ b/backend/data/app/services/wallet_service.py @@ -0,0 +1,52 @@ +from sqlalchemy.ext.asyncio import AsyncSession +from decimal import Decimal +from app.models import Wallet, TransactionType +from app.crud.wallet import get_user_wallet, create_transaction +from app.utils.exceptions import InsufficientFundsError + + +async def deposit_funds(db: AsyncSession, user_id: int, amount: Decimal) -> Wallet: + wallet = await get_user_wallet(db, user_id) + if not wallet: + raise ValueError("Wallet not found") + + wallet.balance += amount + + await create_transaction( + db=db, + user_id=user_id, + wallet_id=wallet.id, + transaction_type=TransactionType.DEPOSIT, + amount=amount, + balance_after=wallet.balance, + description=f"Deposit of ${amount}", + ) + + await db.commit() + await db.refresh(wallet) + return wallet + + +async def withdraw_funds(db: AsyncSession, user_id: int, amount: Decimal) -> Wallet: + wallet = await get_user_wallet(db, user_id) + if not wallet: + raise ValueError("Wallet not found") + + if wallet.balance < amount: + raise InsufficientFundsError() + + wallet.balance -= amount + + await create_transaction( + db=db, + user_id=user_id, + wallet_id=wallet.id, + transaction_type=TransactionType.WITHDRAWAL, + amount=-amount, + balance_after=wallet.balance, + description=f"Withdrawal of ${amount}", + ) + + await db.commit() + await db.refresh(wallet) + return wallet diff --git a/backend/data/app/utils/__init__.py b/backend/data/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/data/app/utils/exceptions.py b/backend/data/app/utils/exceptions.py new file mode 100644 index 0000000..bf30b28 --- /dev/null +++ b/backend/data/app/utils/exceptions.py @@ -0,0 +1,50 @@ +from fastapi import HTTPException, status + + +class BetNotFoundError(HTTPException): + def __init__(self): + super().__init__(status_code=status.HTTP_404_NOT_FOUND, detail="Bet not found") + + +class InsufficientFundsError(HTTPException): + def __init__(self): + super().__init__(status_code=status.HTTP_400_BAD_REQUEST, detail="Insufficient funds") + + +class BetNotAvailableError(HTTPException): + def __init__(self): + super().__init__(status_code=status.HTTP_400_BAD_REQUEST, detail="Bet is no longer available") + + +class CannotAcceptOwnBetError(HTTPException): + def __init__(self): + super().__init__(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot accept your own bet") + + +class UnauthorizedError(HTTPException): + def __init__(self, detail: str = "Not authorized"): + super().__init__(status_code=status.HTTP_401_UNAUTHORIZED, detail=detail) + + +class UserNotFoundError(HTTPException): + def __init__(self): + super().__init__(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + + +class InvalidCredentialsError(HTTPException): + def __init__(self): + super().__init__( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + + +class UserAlreadyExistsError(HTTPException): + def __init__(self, detail: str = "User already exists"): + super().__init__(status_code=status.HTTP_400_BAD_REQUEST, detail=detail) + + +class NotBetParticipantError(HTTPException): + def __init__(self): + super().__init__(status_code=status.HTTP_403_FORBIDDEN, detail="You are not a participant in this bet") diff --git a/backend/data/app/utils/security.py b/backend/data/app/utils/security.py new file mode 100644 index 0000000..f1c35d9 --- /dev/null +++ b/backend/data/app/utils/security.py @@ -0,0 +1,38 @@ +from datetime import datetime, timedelta +from typing import Any +from jose import JWTError, jwt +from passlib.context import CryptContext +from app.config import settings + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + return pwd_context.hash(password) + + +def create_access_token(data: dict[str, Any], expires_delta: timedelta | None = None) -> str: + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM) + return encoded_jwt + + +def create_refresh_token(data: dict[str, Any]) -> str: + to_encode = data.copy() + expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM) + return encoded_jwt + + +def decode_token(token: str) -> dict[str, Any]: + return jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM]) diff --git a/backend/init_spread_betting.py b/backend/init_spread_betting.py new file mode 100644 index 0000000..f8bc1c3 --- /dev/null +++ b/backend/init_spread_betting.py @@ -0,0 +1,217 @@ +""" +Initialize database for spread betting system +Creates tables and seeds with admin user and sample data +""" +import asyncio +from datetime import datetime, timedelta +from decimal import Decimal +from sqlalchemy import select + +from app.database import engine, Base, async_session +from app.models import ( + User, Wallet, SportEvent, AdminSettings, + SportType, EventStatus +) +from app.utils.security import get_password_hash + + +async def init_database(): + """Drop all tables and recreate""" + print("🗑️ Dropping all tables...") + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + print("📦 Creating all tables...") + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + print("✅ Database tables created!") + + +async def seed_data(): + """Seed database with admin user and sample data""" + async with async_session() as db: + print("\n👤 Creating admin user...") + + # Create admin user + admin = User( + email="admin@h2h.com", + username="admin", + password_hash=get_password_hash("admin123"), + display_name="H2H Admin", + is_admin=True + ) + db.add(admin) + await db.flush() + + # Create admin wallet + admin_wallet = Wallet( + user_id=admin.id, + balance=Decimal("10000.00"), + escrow=Decimal("0.00"), + currency="USD" + ) + db.add(admin_wallet) + + print("✅ Admin user created") + print(" 📧 Email: admin@h2h.com") + print(" 🔑 Password: admin123") + + # Create test users + print("\n👥 Creating test users...") + + alice = User( + email="alice@example.com", + username="alice", + password_hash=get_password_hash("password123"), + display_name="Alice" + ) + db.add(alice) + await db.flush() + + alice_wallet = Wallet( + user_id=alice.id, + balance=Decimal("1000.00"), + escrow=Decimal("0.00") + ) + db.add(alice_wallet) + + bob = User( + email="bob@example.com", + username="bob", + password_hash=get_password_hash("password123"), + display_name="Bob" + ) + db.add(bob) + await db.flush() + + bob_wallet = Wallet( + user_id=bob.id, + balance=Decimal("1000.00"), + escrow=Decimal("0.00") + ) + db.add(bob_wallet) + + charlie = User( + email="charlie@example.com", + username="charlie", + password_hash=get_password_hash("password123"), + display_name="Charlie" + ) + db.add(charlie) + await db.flush() + + charlie_wallet = Wallet( + user_id=charlie.id, + balance=Decimal("1000.00"), + escrow=Decimal("0.00") + ) + db.add(charlie_wallet) + + print("✅ Test users created") + print(" alice@example.com / password123 ($1000)") + print(" bob@example.com / password123 ($1000)") + print(" charlie@example.com / password123 ($1000)") + + # Create admin settings + print("\n⚙️ Creating admin settings...") + + settings = AdminSettings( + default_house_commission_percent=Decimal("10.00"), + default_min_bet_amount=Decimal("10.00"), + default_max_bet_amount=Decimal("1000.00"), + default_min_spread=-10.0, + default_max_spread=10.0, + spread_increment=0.5, + platform_name="H2H Sports Betting", + maintenance_mode=False + ) + db.add(settings) + + print("✅ Admin settings created") + print(" 💰 House commission: 10%") + print(" 📊 Spread range: -10 to +10 (0.5 increments)") + print(" 💵 Bet limits: $10 - $1000") + + # Create sample sporting events + print("\n🏈 Creating sample sporting events...") + + # Event 1: Wake Forest vs MS State (tonight) + event1 = SportEvent( + sport=SportType.FOOTBALL, + home_team="Wake Forest", + away_team="MS State", + official_spread=3.0, + game_time=datetime.utcnow() + timedelta(hours=8), + venue="Truist Field", + league="NCAA Football", + min_spread=-10.0, + max_spread=10.0, + min_bet_amount=10.0, + max_bet_amount=1000.0, + created_by=admin.id, + status=EventStatus.UPCOMING + ) + db.add(event1) + + # Event 2: Lakers vs Celtics (tomorrow) + event2 = SportEvent( + sport=SportType.BASKETBALL, + home_team="Los Angeles Lakers", + away_team="Boston Celtics", + official_spread=-5.5, + game_time=datetime.utcnow() + timedelta(days=1, hours=3), + venue="Crypto.com Arena", + league="NBA", + min_spread=-15.0, + max_spread=15.0, + min_bet_amount=10.0, + max_bet_amount=1000.0, + created_by=admin.id, + status=EventStatus.UPCOMING + ) + db.add(event2) + + # Event 3: Chiefs vs Bills (this weekend) + event3 = SportEvent( + sport=SportType.FOOTBALL, + home_team="Kansas City Chiefs", + away_team="Buffalo Bills", + official_spread=-2.5, + game_time=datetime.utcnow() + timedelta(days=3, hours=6), + venue="Arrowhead Stadium", + league="NFL", + min_spread=-14.0, + max_spread=14.0, + min_bet_amount=25.0, + max_bet_amount=2000.0, + created_by=admin.id, + status=EventStatus.UPCOMING + ) + db.add(event3) + + await db.commit() + + print("✅ Sample events created") + print(" 🏈 Wake Forest vs MS State (+3, tonight)") + print(" 🏀 Lakers vs Celtics (-5.5, tomorrow)") + print(" 🏈 Chiefs vs Bills (-2.5, this weekend)") + + print("\n" + "="*60) + print("🎉 Database initialization complete!") + print("="*60) + print("\n📋 Quick Start:") + print(" 1. Login as admin@h2h.com / admin123") + print(" 2. View events at /api/v1/sport-events") + print(" 3. Test users can create and take bets") + print("\n🔗 API Docs: http://localhost:8000/docs") + print("") + + +async def main(): + await init_database() + await seed_data() + + +if __name__ == "__main__": + asyncio.run(main())