commit 14d9af3036077d26bd3f610e6880b869a9957d84 Author: William D. Jones Date: Fri Jan 2 10:43:20 2026 -0600 Init. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7f26e33 --- /dev/null +++ b/.gitignore @@ -0,0 +1,60 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ +ENV/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Database +*.db +*.sqlite +*.sqlite3 + +# Environment +.env +.env.local +.env.*.local + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Node +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* +dist/ +dist-ssr/ +*.local + +# OS +.DS_Store +Thumbs.db + +# Testing +.coverage +htmlcov/ +.pytest_cache/ diff --git a/BLOCKCHAIN_IMPLEMENTATION.md b/BLOCKCHAIN_IMPLEMENTATION.md new file mode 100644 index 0000000..d80bf98 --- /dev/null +++ b/BLOCKCHAIN_IMPLEMENTATION.md @@ -0,0 +1,504 @@ +# Blockchain Hybrid Architecture - Implementation Summary + +## Overview + +This document summarizes the blockchain integration prototype created for the H2H betting platform. The implementation demonstrates a **hybrid architecture** where critical escrow and settlement operations occur on-chain for trustlessness, while maintaining fast queries through database caching. + +## What Has Been Built + +### Phase 1: Smart Contract Architecture ✅ COMPLETE + +**Location**: `backend/app/blockchain/contracts/` + +1. **BetEscrow.pseudocode.md** - Core betting smart contract + - Bet creation (no funds locked yet) + - Atomic escrow locking when bet accepted + - Oracle-based automatic settlement + - Manual settlement fallback + - Dispute mechanism with 48-hour window + - State machine: OPEN → MATCHED → PENDING_ORACLE → COMPLETED + +2. **BetOracle.pseudocode.md** - Decentralized oracle network + - Multi-node consensus (3 of 5 nodes must agree) + - Cryptographic signature verification + - API adapters for sports/entertainment/politics + - Timeout and dispute handling + +3. **README.md** - Complete architecture documentation + - System diagrams + - Data flow charts + - Integration points + - Security model + - Gas optimization strategies + +### Phase 2: Backend Blockchain Services ✅ COMPLETE + +**Location**: `backend/app/blockchain/services/` + +1. **blockchain_service.py** - Web3 Integration Layer + ```python + # Core functions: + - create_bet_on_chain() # Create bet on blockchain + - prepare_accept_bet_transaction() # Prepare tx for user signing + - request_settlement() # Trigger oracle network + - settle_bet_via_oracle() # Submit oracle consensus + - get_bet_from_chain() # Fetch on-chain state + - get_user_escrow() # Get locked funds + - estimate_gas() # Calculate tx costs + ``` + +2. **blockchain_indexer.py** - Event Sync Service + ```python + # Functionality: + - Polls blockchain every 10 seconds for new blocks + - Indexes BetCreated, BetMatched, BetSettled, BetDisputed events + - Syncs on-chain state to PostgreSQL database + - Maintains fast queries while using blockchain as source of truth + - Background worker process + ``` + +3. **oracle_node.py** - Decentralized Oracle Node + ```python + # Functionality: + - Listens for OracleRequested events + - Fetches data from external APIs (ESPN, Oscars, etc.) + - Maps API results to bet winners + - Signs results cryptographically + - Submits to aggregator + - Multiple independent nodes for decentralization + ``` + +4. **oracle_aggregator.py** - Consensus Coordinator + ```python + # Functionality: + - Collects submissions from all oracle nodes + - Verifies cryptographic signatures + - Counts votes for each winner + - Achieves 3/5 consensus threshold + - Submits final result to blockchain + - Handles disputes and timeouts + ``` + +5. **config.py** - Blockchain Configuration + - RPC endpoints (Ethereum, Polygon, Sepolia) + - Contract addresses per network + - Oracle node addresses + - Gas settings + - API endpoints + +### Phase 3: Frontend Web3 Integration ⚡ IN PROGRESS + +**Location**: `frontend/src/blockchain/` + +#### Hooks Created: + +1. **hooks/useWeb3Wallet.ts** - MetaMask Connection + ```typescript + // Features: + - Connect/disconnect wallet + - Listen for account changes + - Listen for network changes + - Link wallet address to backend + - Network switching + ``` + +2. **hooks/useBlockchainBet.ts** - Transaction Management + ```typescript + // Functions: + - createBet() // Create bet on-chain + - acceptBet() // Accept bet (user signs with MetaMask) + - settleBet() // Request oracle settlement + // State: + - txStatus // Transaction status tracking + - isProcessing // Loading state + ``` + +3. **hooks/useGasEstimate.ts** - Gas Cost Estimation + ```typescript + // Provides: + - gasLimit // Estimated gas units + - gasPriceGwei // Current gas price + - costEth // Total cost in ETH + - costUsd // Total cost in USD + - Real-time updates every 30 seconds + ``` + +#### Components Created: + +1. **components/BlockchainBadge.tsx** - On-Chain Indicator + ```typescript + // Variants: + - Confirmed: Green badge with "On-Chain ⛓️" + - Pending: Yellow badge with spinner + - Failed: Red badge with error icon + // Features: + - Links to block explorer (Etherscan) + - Compact mode for lists + - Transaction hash display + ``` + +2. **components/TransactionModal.tsx** - Transaction Status + ```typescript + // States: + - Pending: Waiting for wallet signature + - Confirming: Transaction submitted, awaiting blocks + - Success: Confirmed with explorer link + - Error: Failed with retry button + // Features: + - Auto-close on success + - Animated loading states + - "Do not close" warning + ``` + +## Architecture Diagram + +``` +┌────────────────────────────────────────────────────────────┐ +│ FRONTEND (React + TypeScript) │ +│ │ +│ useWeb3Wallet() ─────> Connect MetaMask │ +│ useBlockchainBet() ──> Sign Transactions │ +│ useGasEstimate() ────> Show Gas Costs │ +│ │ +│ ─> "On-Chain ⛓️" indicator │ +│ ─> Transaction progress │ +└─────────────────────────┬───────────────────────────────────┘ + │ Web3 / MetaMask + ↓ +┌────────────────────────────────────────────────────────────┐ +│ BLOCKCHAIN (Smart Contracts) │ +│ │ +│ BetEscrow Contract BetOracle Contract │ +│ ├─ createBet() ├─ requestSettlement() │ +│ ├─ acceptBet() ├─ submitOracleResponse() │ +│ ├─ settleBet() └─ checkConsensus() │ +│ └─ disputeBet() │ +│ │ +│ Escrow: Holds ETH/MATIC Oracle Nodes: 5 nodes │ +└─────────────────────────┬───────────────────────────────────┘ + │ Events Emitted + ↓ +┌────────────────────────────────────────────────────────────┐ +│ BACKEND (FastAPI + Python) │ +│ │ +│ BlockchainService Oracle Aggregator │ +│ ├─ Web3 provider ├─ Collect node votes │ +│ ├─ Contract calls ├─ Verify consensus │ +│ └─ Gas estimation └─ Submit to chain │ +│ │ +│ BlockchainIndexer Oracle Nodes (3-5) │ +│ ├─ Poll blocks ├─ Fetch ESPN API │ +│ ├─ Index events ├─ Sign results │ +│ └─ Sync to DB └─ Submit to aggregator │ +└─────────────────────────┬───────────────────────────────────┘ + │ Database Sync + ↓ +┌────────────────────────────────────────────────────────────┐ +│ POSTGRESQL DATABASE │ +│ │ +│ Cached bet data for fast queries │ +│ + blockchain_bet_id │ +│ + blockchain_tx_hash │ +│ + blockchain_status │ +└────────────────────────────────────────────────────────────┘ +``` + +## Complete Bet Lifecycle Example + +### 1. Create Bet (Alice) +``` +User fills out CreateBetModal + ↓ +useBlockchainBet.createBet() + ↓ +MetaMask popup: Sign transaction + ↓ +BetEscrow.createBet() on blockchain + ↓ +BetCreated event emitted + ↓ +BlockchainIndexer picks up event + ↓ +Database updated with blockchain_bet_id + ↓ +Bet appears in marketplace with "On-Chain ⛓️" badge +``` + +### 2. Accept Bet (Bob) +``` +Bob clicks "Accept Bet" + ↓ +GasFeeEstimate shows: "~$2.00" (on Polygon) + ↓ +Bob confirms + ↓ +useBlockchainBet.acceptBet() + ↓ +MetaMask popup: Sign transaction with stake + ↓ +TransactionModal: "Confirming... Do not close" + ↓ +BetEscrow.acceptBet() locks funds for both parties + ↓ +BetMatched event emitted + ↓ +TransactionModal: "Success!" with Etherscan link + ↓ +BlockchainIndexer syncs to database + ↓ +Both users see bet status: "Matched ⛓️" + ↓ +Escrow locked: Alice $100, Bob $100 +``` + +### 3. Oracle Settlement (Automatic) +``` +Real-world event ends (Super Bowl) + ↓ +BetEscrow.requestSettlement() + ↓ +OracleRequested event emitted + ↓ +5 Oracle Nodes: + Node 1: Fetch ESPN API → "49ers won" → Sign → Submit + Node 2: Fetch ESPN API → "49ers won" → Sign → Submit + Node 3: Fetch ESPN API → "49ers won" → Sign → Submit + Node 4: Fetch ESPN API → "49ers won" → Sign → Submit + Node 5: Fetch ESPN API → "49ers won" → Sign → Submit + ↓ +OracleAggregator: + - 5/5 nodes agree on "49ers won" + - Maps to Alice's address (she bet on 49ers) + - Consensus achieved (5 >= 3 threshold) + ↓ +BetOracle.fulfillSettlement(winner=Alice) + ↓ +BetEscrow.settleBet() transfers $200 to Alice + ↓ +BetSettled event emitted + ↓ +BlockchainIndexer updates database + ↓ +Alice's wallet: +$200 ETH +Bob's wallet: -$100 (lost from escrow) + ↓ +Frontend shows: "Completed ⛓️" +``` + +## Visual UI Indicators + +### Where Blockchain Badges Appear: + +1. **Marketplace (BetCard)** + ``` + ┌─────────────────────────────────────┐ + │ Super Bowl LVIII Winner │ + │ ┌────────┐ ┌─────────────────────┐ │ + │ │ MATCHED│ │ On-Chain ⛓️ │ │ + │ └────────┘ └─────────────────────┘ │ + │ Sports • $100 • 1.5x / 2x │ + │ │ + │ [View Details] │ + └─────────────────────────────────────┘ + ``` + +2. **Bet Details Page** + ``` + Bet Details: Super Bowl LVIII Winner + Status: MATCHED [On-Chain ⛓️] [View on Etherscan ↗] + + Escrow: $200 locked in smart contract + ``` + +3. **Wallet Page** + ``` + Wallet Balance + ────────────────────────────────────── + Available Balance: $725.00 + Locked in Escrow: $175.00 [On-Chain ⛓️] + Total: $900.00 + + On-Chain Escrow Details: + - 2 active bets + - Smart contract: 0x1234... + ``` + +### Transaction Flow Example: + +``` +User clicks "Accept Bet - $100" + ↓ +┌──────────────────────────────────────┐ +│ Gas Fee Estimate │ +├──────────────────────────────────────┤ +│ Estimated Gas: 180,000 units │ +│ Gas Price: 50 gwei │ +│ │ +│ Total Cost: 0.009 ETH ≈ $18.00 │ +│ │ +│ [Cancel] [Confirm] │ +└──────────────────────────────────────┘ + ↓ User clicks Confirm + ↓ +┌──────────────────────────────────────┐ +│ MetaMask popup appears │ +│ "Confirm transaction in wallet..." │ +└──────────────────────────────────────┘ + ↓ User signs in MetaMask + ↓ +┌──────────────────────────────────────┐ +│ 🔄 Confirming Transaction │ +│ │ +│ Your transaction is being processed │ +│ on the blockchain │ +│ │ +│ [Spinner animation] │ +│ │ +│ View on Etherscan ↗ │ +│ │ +│ Do not close this window │ +└──────────────────────────────────────┘ + ↓ Wait 10-30 seconds + ↓ +┌──────────────────────────────────────┐ +│ ✓ Transaction Confirmed! │ +│ │ +│ Your bet has been accepted │ +│ │ +│ TX: 0xabc123... │ +│ View on Block Explorer ↗ │ +│ │ +│ [Close] │ +└──────────────────────────────────────┘ +``` + +## File Structure + +``` +h2h-prototype/ +├── backend/ +│ └── app/ +│ └── blockchain/ +│ ├── __init__.py +│ ├── config.py [✅ DONE] +│ ├── contracts/ +│ │ ├── BetEscrow.pseudocode.md [✅ DONE] +│ │ ├── BetOracle.pseudocode.md [✅ DONE] +│ │ └── README.md [✅ DONE] +│ └── services/ +│ ├── __init__.py [✅ DONE] +│ ├── blockchain_service.py [✅ DONE] +│ ├── blockchain_indexer.py [✅ DONE] +│ ├── oracle_node.py [✅ DONE] +│ └── oracle_aggregator.py [✅ DONE] +│ +└── frontend/ + └── src/ + └── blockchain/ + ├── hooks/ + │ ├── useWeb3Wallet.ts [✅ DONE] + │ ├── useBlockchainBet.ts [✅ DONE] + │ └── useGasEstimate.ts [✅ DONE] + ├── components/ + │ ├── BlockchainBadge.tsx [✅ DONE] + │ ├── TransactionModal.tsx [✅ DONE] + │ ├── GasFeeEstimate.tsx [⏳ TODO] + │ ├── Web3WalletButton.tsx [⏳ TODO] + │ └── WalletBalanceBlockchain.tsx [⏳ TODO] + └── config/ + └── contracts.ts [⏳ TODO] +``` + +## Remaining Work + +### Phase 3 (Continued): +- [ ] Create GasFeeEstimate.tsx component +- [ ] Create Web3WalletButton.tsx component +- [ ] Create WalletBalanceBlockchain.tsx component +- [ ] Create contracts.ts configuration + +### Phase 4: +- [ ] Modify BetCard.tsx to add +- [ ] Modify CreateBetModal.tsx to add transaction flow +- [ ] Modify BetDetail.tsx to add gas estimate +- [ ] Modify WalletBalance.tsx to show on-chain escrow +- [ ] Modify Header.tsx to add wallet connect button + +### Phase 5: +- [ ] Update Bet model to add blockchain fields: + - blockchain_bet_id: int | None + - blockchain_tx_hash: str | None + - blockchain_status: str | None +- [ ] Create database migration + +## Key Design Decisions + +### Hybrid vs Pure On-Chain + +| Aspect | On-Chain (Blockchain) | Off-Chain (Database) | +|--------|----------------------|---------------------| +| **Escrow funds** | ✅ Smart contract holds ETH | ❌ | +| **Bet status** | ✅ Source of truth | ✅ Cached for speed | +| **Settlement** | ✅ Oracle-based | ❌ | +| **User auth** | ❌ | ✅ JWT tokens | +| **Bet metadata** | ❌ | ✅ Title, description | +| **Search/filters** | ❌ Too slow | ✅ PostgreSQL | +| **Notifications** | ❌ | ✅ WebSocket | + +**Rationale**: Blockchain for trustlessness, database for performance. + +### Oracle Consensus + +- **Nodes**: 5 independent servers +- **Threshold**: 3 of 5 must agree +- **APIs**: ESPN, Oscars, Election APIs +- **Fallback**: Manual settlement after 24 hours +- **Dispute**: 48-hour window for participants + +### Gas Optimization + +- **Layer 2**: Deploy on Polygon for 90% lower fees + - Ethereum: ~$18 per bet acceptance + - Polygon: ~$2 per bet acceptance +- **Batch Operations**: Future enhancement +- **Meta-Transactions**: Backend pays gas (EIP-2771) + +## Security Considerations + +1. **Smart Contract** + - Reentrancy guards + - Role-based access control + - Atomic transactions + - Security audit required before mainnet + +2. **Oracle Network** + - Multi-node consensus (3/5) + - Cryptographic signatures + - Timeout handling + - Admin override for edge cases + +3. **Frontend** + - Never expose private keys + - Verify contract addresses + - Show gas estimates before transactions + - Warn users about network fees + +## Next Steps + +1. **Complete remaining frontend components** +2. **Update database models with blockchain fields** +3. **Integrate blockchain badges into existing UI** +4. **Test on Sepolia testnet** +5. **Security audit** +6. **Deploy to Polygon mainnet** + +## References + +- Smart Contract Architecture: `backend/app/blockchain/contracts/README.md` +- Backend Services: `backend/app/blockchain/services/` +- Frontend Integration: `frontend/src/blockchain/` +- Implementation Plan: `/Users/liamdeez/.claude/plans/whimsical-napping-sedgewick.md` + +--- + +**Status**: Phase 1 ✅ Complete | Phase 2 ✅ Complete | Phase 3 ⚡ 75% Complete | Phase 4 ⏳ Pending | Phase 5 ⏳ Pending diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..363eed7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,216 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +H2H (Head to Head) is a peer-to-peer betting platform MVP where users can create, accept, and settle wagers directly with other users. The platform demonstrates core betting workflows with escrow, real-time updates, and a marketplace. + +## Tech Stack + +- **Backend**: FastAPI (Python 3.11+) with async SQLAlchemy ORM +- **Database**: SQLite (via aiosqlite) - designed for easy migration to PostgreSQL +- **Frontend**: React 18+ with Vite, TypeScript, TailwindCSS +- **Authentication**: JWT tokens with refresh mechanism +- **Real-time**: WebSockets for live updates +- **State Management**: Zustand for client state, React Query for server state + +## Commands + +### Backend Development + +```bash +# Setup +cd backend +python -m venv venv +source venv/bin/activate # Windows: venv\Scripts\activate +pip install -r requirements.txt + +# Initialize database +python seed_data.py + +# Run development server +uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 + +# Run with Docker +docker-compose up backend +``` + +### Frontend Development + +```bash +# Setup +cd frontend +npm install + +# Run development server +npm run dev + +# Build for production +npm run build + +# Type checking +npm run type-check # If configured + +# Run with Docker +docker-compose up frontend +``` + +### Full Stack + +```bash +# Start both services +docker-compose up --build + +# Stop all services +docker-compose down + +# Reset database (when needed) +docker-compose down -v +``` + +## Architecture + +### Core Data Flow + +1. **Bet Creation Flow**: + - User creates bet → Validates funds → Bet enters marketplace (status: OPEN) + - No funds locked until bet is accepted + +2. **Bet Matching Flow**: + - User accepts bet → Validates opponent funds → Locks funds in escrow for BOTH parties → Updates bet status to MATCHED + - Both creator and acceptor have funds locked in wallet.escrow + +3. **Settlement Flow**: + - Winner submits claim → Loser confirms OR disputes → Funds distributed or flagged for review + - On confirm: escrow released, winner receives stake from both sides + - On dispute: bet marked DISPUTED for manual resolution + +### Database Architecture + +**Critical relationship pattern**: The Bet model uses multiple foreign keys to User: +- `creator_id` → User who created the bet +- `opponent_id` → User who accepted the bet (nullable until matched) +- `winner_id` → User who won (nullable until settled) + +When querying bets with relationships, use `selectinload()` or `joinedload()` to prevent N+1 queries: + +```python +query = select(Bet).options( + selectinload(Bet.creator), + selectinload(Bet.opponent) +) +``` + +### Async SQLAlchemy Patterns + +All database operations are async. Key patterns: + +**Transaction safety for critical operations**: +```python +async with db.begin_nested(): # Creates savepoint + bet = await db.get(Bet, bet_id, with_for_update=True) # Row lock + wallet.balance -= amount + wallet.escrow += amount + # All changes committed together or rolled back +``` + +**CRUD operations must use async methods**: +- `await db.execute(query)` not `db.execute(query)` +- `await db.commit()` not `db.commit()` +- `await db.refresh(obj)` not `db.refresh(obj)` + +### Wallet & Escrow System + +The wallet has two balance fields: +- `balance`: Available funds the user can use +- `escrow`: Locked funds in active bets + +**Critical invariant**: `balance + escrow` should always equal total funds. When accepting a bet: +1. Deduct from `balance` +2. Add to `escrow` +3. Create ESCROW_LOCK transaction record + +When settling: +1. Deduct from loser's `escrow` +2. Add to winner's `balance` +3. Deduct from winner's `escrow` (their original stake) +4. Add to winner's `balance` (their original stake returned) + +### WebSocket Events + +WebSocket connections authenticated via query param: `ws://host/api/v1/ws?token={jwt}` + +Event types broadcast to relevant users: +- `bet_created`: New bet in marketplace +- `bet_accepted`: Bet matched with opponent +- `bet_settled`: Bet result confirmed +- `wallet_updated`: Balance or escrow changed +- `notification`: General user notifications + +## Key Constraints + +### Bet State Transitions + +Valid state transitions: +- OPEN → MATCHED (via accept) +- OPEN → CANCELLED (creator cancels before match) +- MATCHED → IN_PROGRESS (event starts) +- IN_PROGRESS → PENDING_RESULT (event ends, awaiting settlement) +- PENDING_RESULT → COMPLETED (settlement confirmed) +- PENDING_RESULT → DISPUTED (settlement disputed) + +Invalid: Cannot cancel MATCHED bets, cannot settle OPEN bets + +### Validation Rules + +- Stake amount: Must be > 0, ≤ $10,000 +- User cannot accept their own bet +- User must have sufficient balance to accept bet +- Only creator can modify/cancel OPEN bets +- Only bet participants can settle +- Odds must be > 0 + +## Environment Configuration + +Backend requires (see `backend/.env.example`): +- `DATABASE_URL`: SQLite path (default: `sqlite+aiosqlite:///./data/h2h.db`) +- `JWT_SECRET`: Secret for signing tokens +- `JWT_ALGORITHM`: HS256 +- `ACCESS_TOKEN_EXPIRE_MINUTES`: Token TTL + +Frontend requires (see `frontend/.env.example`): +- `VITE_API_URL`: Backend API URL (default: `http://localhost:8000`) +- `VITE_WS_URL`: WebSocket URL (default: `ws://localhost:8000`) + +## Migration to Production Database + +The codebase is designed for easy migration from SQLite to PostgreSQL: + +1. Update `DATABASE_URL` to PostgreSQL connection string: + ```python + DATABASE_URL = "postgresql+asyncpg://user:pass@host:5432/h2h" + ``` + +2. Update `requirements.txt`: + - Remove: `aiosqlite` + - Add: `asyncpg` + +3. Run Alembic migrations against new database + +4. No code changes needed - SQLAlchemy ORM abstracts database differences + +## Testing Data + +Run `python seed_data.py` to create test users: +- alice@example.com / password123 +- bob@example.com / password123 +- charlie@example.com / password123 + +Each starts with $1000 balance and sample bets in marketplace. + +## MVP Scope + +**In scope**: User auth, virtual wallet, bet creation/acceptance, basic settlement, WebSocket updates, responsive UI + +**Out of scope** (future): Real payments, KYC/AML, blockchain, odds feeds, admin panel, email notifications, social features diff --git a/H2H_MVP_Claude_Code_Prompt_SQLite.md b/H2H_MVP_Claude_Code_Prompt_SQLite.md new file mode 100644 index 0000000..46834c7 --- /dev/null +++ b/H2H_MVP_Claude_Code_Prompt_SQLite.md @@ -0,0 +1,982 @@ +# H2H Betting Platform - MVP Prototype Generation Prompt + +## Project Overview + +Build a prototype MVP for **Head to Head Bet (H2H)** — a peer-to-peer betting platform where users can create, accept, and settle wagers directly with other users. This is a functional prototype to demonstrate core betting workflows. + +## Tech Stack + +- **Backend:** FastAPI (Python 3.11+) +- **Frontend:** React 18+ with Vite, TypeScript, TailwindCSS +- **Database:** SQLite with SQLAlchemy ORM (async via aiosqlite) +- **Authentication:** JWT tokens +- **Real-time:** WebSockets for live updates + +> **Note:** SQLite is used for rapid prototyping. The SQLAlchemy ORM allows easy migration to PostgreSQL or MySQL later by simply changing the connection string. + +## Project Structure + +``` +h2h-mvp/ +├── backend/ +│ ├── app/ +│ │ ├── __init__.py +│ │ ├── main.py # FastAPI app entry point +│ │ ├── config.py # Settings and environment config +│ │ ├── database.py # SQLAlchemy engine, session, Base +│ │ ├── models/ +│ │ │ ├── __init__.py # Export all models +│ │ │ ├── user.py +│ │ │ ├── bet.py +│ │ │ ├── wallet.py +│ │ │ └── transaction.py +│ │ ├── schemas/ +│ │ │ ├── __init__.py +│ │ │ ├── user.py +│ │ │ ├── bet.py +│ │ │ └── wallet.py +│ │ ├── routers/ +│ │ │ ├── __init__.py +│ │ │ ├── auth.py +│ │ │ ├── users.py +│ │ │ ├── bets.py +│ │ │ ├── wallet.py +│ │ │ └── websocket.py +│ │ ├── services/ +│ │ │ ├── __init__.py +│ │ │ ├── auth_service.py +│ │ │ ├── bet_service.py # Bet matching and settlement +│ │ │ ├── wallet_service.py +│ │ │ └── notification.py +│ │ ├── crud/ +│ │ │ ├── __init__.py +│ │ │ ├── user.py +│ │ │ ├── bet.py +│ │ │ └── wallet.py +│ │ └── utils/ +│ │ ├── __init__.py +│ │ ├── security.py # Password hashing, JWT +│ │ └── validators.py +│ ├── data/ +│ │ └── .gitkeep # SQLite db file goes here +│ ├── alembic/ # Database migrations +│ │ ├── versions/ +│ │ ├── env.py +│ │ └── alembic.ini +│ ├── requirements.txt +│ ├── Dockerfile +│ ├── seed_data.py # Script to populate test data +│ └── .env.example +├── frontend/ +│ ├── src/ +│ │ ├── main.tsx +│ │ ├── App.tsx +│ │ ├── api/ +│ │ │ ├── client.ts # Axios instance +│ │ │ ├── auth.ts +│ │ │ ├── bets.ts +│ │ │ └── wallet.ts +│ │ ├── components/ +│ │ │ ├── layout/ +│ │ │ │ ├── Header.tsx +│ │ │ │ ├── Sidebar.tsx +│ │ │ │ └── Layout.tsx +│ │ │ ├── auth/ +│ │ │ │ ├── LoginForm.tsx +│ │ │ │ └── RegisterForm.tsx +│ │ │ ├── bets/ +│ │ │ │ ├── BetCard.tsx +│ │ │ │ ├── BetList.tsx +│ │ │ │ ├── CreateBetModal.tsx +│ │ │ │ ├── BetMatchingQueue.tsx +│ │ │ │ └── MyBets.tsx +│ │ │ ├── wallet/ +│ │ │ │ ├── WalletBalance.tsx +│ │ │ │ ├── DepositModal.tsx +│ │ │ │ └── TransactionHistory.tsx +│ │ │ └── common/ +│ │ │ ├── Button.tsx +│ │ │ ├── Modal.tsx +│ │ │ ├── Card.tsx +│ │ │ └── Loading.tsx +│ │ ├── pages/ +│ │ │ ├── Home.tsx +│ │ │ ├── Login.tsx +│ │ │ ├── Register.tsx +│ │ │ ├── Dashboard.tsx +│ │ │ ├── BetMarketplace.tsx +│ │ │ ├── CreateBet.tsx +│ │ │ ├── BetDetails.tsx +│ │ │ ├── MyBets.tsx +│ │ │ └── Wallet.tsx +│ │ ├── hooks/ +│ │ │ ├── useAuth.ts +│ │ │ ├── useBets.ts +│ │ │ ├── useWallet.ts +│ │ │ └── useWebSocket.ts +│ │ ├── store/ +│ │ │ ├── index.ts # Zustand store +│ │ │ ├── authSlice.ts +│ │ │ └── betSlice.ts +│ │ ├── types/ +│ │ │ └── index.ts +│ │ └── utils/ +│ │ ├── formatters.ts +│ │ └── constants.ts +│ ├── index.html +│ ├── package.json +│ ├── vite.config.ts +│ ├── tailwind.config.js +│ ├── tsconfig.json +│ └── .env.example +├── docker-compose.yml +└── README.md +``` + +--- + +## Database Schema (SQLAlchemy Models) + +### database.py - Connection Setup +```python +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker +from sqlalchemy.orm import DeclarativeBase + +DATABASE_URL = "sqlite+aiosqlite:///./data/h2h.db" + +engine = create_async_engine(DATABASE_URL, echo=True) +async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + +class Base(DeclarativeBase): + pass + +async def get_db(): + async with async_session() as session: + try: + yield session + finally: + await session.close() + +async def init_db(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) +``` + +### User Model +```python +from sqlalchemy import String, DateTime, Enum, Float, Integer +from sqlalchemy.orm import Mapped, mapped_column, relationship +from datetime import datetime +import enum + +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] = mapped_column(String(100), nullable=True) + avatar_url: Mapped[str] = mapped_column(String(500), nullable=True) + bio: Mapped[str] = 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) + + 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") +``` + +### Wallet Model +```python +from sqlalchemy import ForeignKey, Numeric +from decimal import Decimal + +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") +``` + +### Transaction Model +```python +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] = mapped_column(Integer, nullable=True) # bet_id if bet-related + 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") +``` + +### Bet Model +```python +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] = 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] = 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] = mapped_column(ForeignKey("users.id"), nullable=True) + settled_at: Mapped[datetime] = mapped_column(DateTime, nullable=True) + settled_by: Mapped[str] = mapped_column(String(20), nullable=True) # creator, opponent, admin, oracle + + expires_at: Mapped[datetime] = 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]) + winner: Mapped["User"] = relationship(foreign_keys=[winner_id]) + proposals: Mapped[list["BetProposal"]] = relationship(back_populates="bet") +``` + +### BetProposal Model +```python +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] = 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") + proposer: Mapped["User"] = relationship() +``` + +--- + +## API Endpoints + +### Authentication +``` +POST /api/v1/auth/register # Register new user +POST /api/v1/auth/login # Login, returns JWT +POST /api/v1/auth/refresh # Refresh JWT token +POST /api/v1/auth/logout # Invalidate token +GET /api/v1/auth/me # Get current user +``` + +### Users +``` +GET /api/v1/users/{user_id} # Get user profile +PUT /api/v1/users/me # Update own profile +GET /api/v1/users/{user_id}/stats # Get user betting stats +GET /api/v1/users/{user_id}/bets # Get user's public bets +``` + +### Wallet +``` +GET /api/v1/wallet # Get wallet balance +POST /api/v1/wallet/deposit # Simulate deposit (MVP) +POST /api/v1/wallet/withdraw # Simulate withdrawal (MVP) +GET /api/v1/wallet/transactions # Get transaction history +``` + +### Bets +``` +GET /api/v1/bets # List open bets (marketplace) +POST /api/v1/bets # Create new bet +GET /api/v1/bets/{bet_id} # Get bet details +PUT /api/v1/bets/{bet_id} # Update bet (creator only, if open) +DELETE /api/v1/bets/{bet_id} # Cancel bet (creator only, if open) + +POST /api/v1/bets/{bet_id}/accept # Accept a bet +POST /api/v1/bets/{bet_id}/propose # Counter-propose terms +POST /api/v1/bets/{bet_id}/settle # Submit result (winner claims) +POST /api/v1/bets/{bet_id}/confirm # Confirm result (loser confirms) +POST /api/v1/bets/{bet_id}/dispute # Dispute result + +GET /api/v1/bets/my/created # My created bets +GET /api/v1/bets/my/accepted # Bets I've accepted +GET /api/v1/bets/my/active # All my active bets +GET /api/v1/bets/my/history # Completed bet history +``` + +### WebSocket +``` +WS /api/v1/ws # Real-time updates + - bet_created + - bet_accepted + - bet_settled + - wallet_updated + - notification +``` + +--- + +## Core Features for MVP + +### 1. User Authentication +- Email/password registration with validation +- JWT-based authentication with refresh tokens +- Basic profile management + +### 2. Wallet System +- Virtual wallet with USD balance +- Simulated deposits (for testing - just add funds) +- Escrow mechanism (lock funds when bet is placed/accepted) +- Transaction history + +### 3. Bet Creation +- Create bets with title, description, terms +- Set stake amount and simple odds +- Choose category and visibility +- Set expiration for bet offers + +### 4. Bet Marketplace +- Browse open bets with filters (category, stake range, etc.) +- Search functionality +- Sort by newest, stake amount, expiring soon + +### 5. Bet Matching +- Accept existing bets +- Funds automatically escrowed from both parties +- Real-time notifications via WebSocket + +### 6. Bet Settlement +- Winner submits result claim +- Loser confirms OR disputes +- If confirmed: funds distributed automatically +- If disputed: marked for admin review (MVP just flags it) + +### 7. Dashboard +- Overview of active bets +- Recent activity +- Win/loss stats +- Wallet balance + +--- + +## Implementation Notes + +### Backend (FastAPI + SQLAlchemy) + +1. **Async SQLAlchemy setup:** +```python +# database.py +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker +from sqlalchemy.orm import DeclarativeBase + +DATABASE_URL = "sqlite+aiosqlite:///./data/h2h.db" + +engine = create_async_engine( + DATABASE_URL, + echo=True, # SQL logging, disable in production + connect_args={"check_same_thread": False} # SQLite specific +) + +async_session = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False +) +``` + +2. **Dependency injection:** +```python +from sqlalchemy.ext.asyncio import AsyncSession + +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 get_current_user( + token: str = Depends(oauth2_scheme), + db: AsyncSession = Depends(get_db) +) -> User: + # Validate JWT and return user from database + pass +``` + +3. **CRUD operations:** +```python +# crud/bet.py +from sqlalchemy import select +from sqlalchemy.orm import selectinload + +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 result.scalars().all() + +async def create_bet(db: AsyncSession, bet_data: BetCreate, user_id: int) -> Bet: + bet = Bet(**bet_data.model_dump(), creator_id=user_id) + db.add(bet) + await db.flush() + await db.refresh(bet) + return bet +``` + +4. **Bet service with transaction:** +```python +# services/bet_service.py +async def accept_bet(db: AsyncSession, bet_id: int, user_id: int) -> Bet: + # Use database transaction for atomic operations + async with db.begin_nested(): # Savepoint + # 1. 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() + + # 2. Get user's wallet and verify funds + wallet = await get_user_wallet(db, user_id) + if wallet.balance < bet.stake_amount: + raise InsufficientFundsError() + + # 3. Lock funds in escrow + wallet.balance -= bet.stake_amount + wallet.escrow += bet.stake_amount + + # 4. Update bet + bet.opponent_id = user_id + bet.status = BetStatus.MATCHED + + # 5. Create transaction record + transaction = Transaction( + user_id=user_id, + wallet_id=wallet.id, + type=TransactionType.ESCROW_LOCK, + amount=-bet.stake_amount, + balance_after=wallet.balance, + reference_id=bet.id, + description=f"Escrow for bet: {bet.title}" + ) + db.add(transaction) + + await db.commit() + await db.refresh(bet) + return bet +``` + +5. **Pydantic schemas:** +```python +# schemas/bet.py +from pydantic import BaseModel, Field +from decimal import Decimal +from datetime import datetime + +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 BetResponse(BaseModel): + id: int + title: str + description: str + category: BetCategory + status: BetStatus + stake_amount: Decimal + creator: UserSummary + opponent: UserSummary | None + created_at: datetime + + model_config = ConfigDict(from_attributes=True) +``` + +6. **Error handling:** +```python +# utils/exceptions.py +from fastapi import HTTPException + +class BetNotFoundError(HTTPException): + def __init__(self): + super().__init__(status_code=404, detail="Bet not found") + +class InsufficientFundsError(HTTPException): + def __init__(self): + super().__init__(status_code=400, detail="Insufficient funds") + +class BetNotAvailableError(HTTPException): + def __init__(self): + super().__init__(status_code=400, detail="Bet is no longer available") +``` + +### Frontend (React + Vite) + +1. **State management with Zustand:** +```typescript +interface AuthState { + user: User | null; + token: string | null; + isAuthenticated: boolean; + login: (email: string, password: string) => Promise; + logout: () => void; +} +``` + +2. **React Query for server state:** +```typescript +const { data: bets, isLoading } = useQuery({ + queryKey: ['bets', filters], + queryFn: () => fetchBets(filters), +}); +``` + +3. **WebSocket hook:** +```typescript +const useWebSocket = () => { + useEffect(() => { + const ws = new WebSocket(`${WS_URL}?token=${token}`); + ws.onmessage = (event) => { + const data = JSON.parse(event.data); + // Handle different event types + }; + return () => ws.close(); + }, [token]); +}; +``` + +4. **Tailwind styling** - Use consistent design system: + - Primary: Blue (#3B82F6) + - Success: Green (#10B981) + - Warning: Yellow (#F59E0B) + - Error: Red (#EF4444) + - Dark theme support + +--- + +## Requirements Files + +### backend/requirements.txt +``` +fastapi==0.109.0 +uvicorn[standard]==0.27.0 +sqlalchemy[asyncio]==2.0.25 +aiosqlite==0.19.0 +pydantic==2.5.3 +pydantic-settings==2.1.0 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +python-multipart==0.0.6 +websockets==12.0 +alembic==1.13.1 +httpx==0.26.0 +``` + +### frontend/package.json (dependencies) +```json +{ + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.21.0", + "@tanstack/react-query": "^5.17.0", + "zustand": "^4.4.7", + "axios": "^1.6.5", + "date-fns": "^3.2.0", + "lucide-react": "^0.303.0" + }, + "devDependencies": { + "@types/react": "^18.2.48", + "@types/react-dom": "^18.2.18", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.17", + "postcss": "^8.4.33", + "tailwindcss": "^3.4.1", + "typescript": "^5.3.3", + "vite": "^5.0.12" + } +} +``` + +--- + +## Docker Compose Setup + +```yaml +version: '3.8' + +services: + backend: + build: ./backend + ports: + - "8000:8000" + environment: + - DATABASE_URL=sqlite+aiosqlite:///./data/h2h.db + - JWT_SECRET=${JWT_SECRET:-your-secret-key-change-in-production} + - JWT_ALGORITHM=HS256 + - ACCESS_TOKEN_EXPIRE_MINUTES=30 + volumes: + - ./backend:/app + - sqlite_data:/app/data + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + + frontend: + build: ./frontend + ports: + - "5173:5173" + environment: + - VITE_API_URL=http://localhost:8000 + - VITE_WS_URL=ws://localhost:8000 + volumes: + - ./frontend:/app + - /app/node_modules + command: npm run dev -- --host + +volumes: + sqlite_data: +``` + +--- + +## Seed Data Script + +```python +# backend/seed_data.py +import asyncio +from decimal import Decimal +from datetime import datetime, timedelta +from app.database import async_session, init_db +from app.models import User, Wallet, Bet, BetCategory, BetStatus, BetVisibility +from app.utils.security import get_password_hash + +async def seed(): + await init_db() + + async with async_session() as db: + # Create test users + users = [ + User( + email="alice@example.com", + username="alice", + password_hash=get_password_hash("password123"), + display_name="Alice Smith" + ), + User( + email="bob@example.com", + username="bob", + password_hash=get_password_hash("password123"), + display_name="Bob Jones" + ), + User( + email="charlie@example.com", + username="charlie", + password_hash=get_password_hash("password123"), + display_name="Charlie Brown" + ), + ] + + for user in users: + db.add(user) + await db.flush() + + # Create wallets with starting balance + for user in users: + wallet = Wallet( + user_id=user.id, + balance=Decimal("1000.00"), + escrow=Decimal("0.00") + ) + db.add(wallet) + + # Create sample bets + bets = [ + Bet( + creator_id=users[0].id, + title="Super Bowl LVIII Winner", + description="Bet on who will win Super Bowl LVIII", + category=BetCategory.SPORTS, + event_name="Super Bowl LVIII", + event_date=datetime(2024, 2, 11), + creator_position="Kansas City Chiefs win", + opponent_position="San Francisco 49ers win", + stake_amount=Decimal("100.00"), + creator_odds=1.5, + opponent_odds=2.0, + visibility=BetVisibility.PUBLIC, + expires_at=datetime.utcnow() + timedelta(days=7) + ), + Bet( + creator_id=users[1].id, + title="League of Legends Worlds 2024", + description="Who will win the LoL World Championship?", + category=BetCategory.ESPORTS, + event_name="LoL Worlds 2024", + creator_position="T1 wins", + opponent_position="Any other team wins", + stake_amount=Decimal("50.00"), + creator_odds=1.8, + opponent_odds=1.8, + visibility=BetVisibility.PUBLIC, + expires_at=datetime.utcnow() + timedelta(days=14) + ), + Bet( + creator_id=users[2].id, + title="Oscar Best Picture 2024", + description="Which film will win Best Picture at the 2024 Oscars?", + category=BetCategory.ENTERTAINMENT, + event_name="96th Academy Awards", + event_date=datetime(2024, 3, 10), + creator_position="Oppenheimer wins", + opponent_position="Any other film wins", + stake_amount=Decimal("25.00"), + creator_odds=1.3, + opponent_odds=3.0, + visibility=BetVisibility.PUBLIC, + expires_at=datetime.utcnow() + timedelta(days=30) + ), + ] + + for bet in bets: + db.add(bet) + + await db.commit() + print("Seed data created successfully!") + +if __name__ == "__main__": + asyncio.run(seed()) +``` + +--- + +## MVP Scope Boundaries + +### In Scope (Build This) +- User registration/login with JWT +- Virtual wallet with simulated deposits +- Create, browse, accept bets +- Basic bet settlement (winner claims, loser confirms) +- Real-time updates via WebSocket +- Responsive UI (mobile-friendly) + +### Out of Scope (Future) +- Real payment processing +- KYC/AML verification +- Blockchain integration +- Odds feed integration +- Complex matching algorithms +- Admin panel +- Email notifications +- Social features (friends, chat) + +--- + +## Migration Path to Production Database + +When ready to move from SQLite to PostgreSQL: + +1. **Update connection string:** +```python +# Change from: +DATABASE_URL = "sqlite+aiosqlite:///./data/h2h.db" + +# To: +DATABASE_URL = "postgresql+asyncpg://user:pass@host:5432/h2h" +``` + +2. **Update requirements:** +``` +# Remove: aiosqlite +# Add: asyncpg +``` + +3. **Run Alembic migrations** against new database + +4. **Update Docker Compose** to include PostgreSQL service + +That's it! SQLAlchemy ORM handles the rest. + +--- + +## Getting Started Commands + +```bash +# Clone and setup +git clone +cd h2h-mvp + +# Start with Docker +docker-compose up --build + +# Or run separately: + +# Backend +cd backend +python -m venv venv +source venv/bin/activate # Windows: venv\Scripts\activate +pip install -r requirements.txt +python seed_data.py # Create test data +uvicorn app.main:app --reload + +# Frontend +cd frontend +npm install +npm run dev +``` + +--- + +## Success Criteria + +The MVP is complete when: + +1. ✅ User can register, login, and manage profile +2. ✅ User can deposit virtual funds to wallet +3. ✅ User can create a bet with custom terms +4. ✅ User can browse and search open bets +5. ✅ User can accept another user's bet +6. ✅ Funds are properly escrowed on bet acceptance +7. ✅ Winner can claim victory, loser can confirm +8. ✅ Funds are distributed correctly on settlement +9. ✅ Real-time updates work via WebSocket +10. ✅ UI is clean, responsive, and intuitive + +--- + +## Generate This Now + +Please generate the complete MVP codebase following this specification. Start with the backend (FastAPI + SQLAlchemy + SQLite), then the frontend (React + Vite + TypeScript + Tailwind). Include: + +1. All files in the project structure +2. Working Docker Compose setup +3. Environment variable examples +4. README with setup instructions +5. Seed data script for testing +6. Alembic migration setup + +Focus on clean, production-quality code with proper error handling, validation, and TypeScript types. Use async/await throughout the backend. diff --git a/README.md b/README.md new file mode 100644 index 0000000..508e035 --- /dev/null +++ b/README.md @@ -0,0 +1,196 @@ +# H2H - Peer-to-Peer Betting Platform MVP + +A functional prototype of a peer-to-peer betting platform where users can create, accept, and settle wagers directly with other users. + +## Tech Stack + +- **Backend**: FastAPI (Python 3.11+) with async SQLAlchemy ORM +- **Database**: SQLite (via aiosqlite) - designed for easy migration to PostgreSQL +- **Frontend**: React 18+ with Vite, TypeScript, TailwindCSS +- **Authentication**: JWT tokens with refresh mechanism +- **Real-time**: WebSockets for live updates +- **State Management**: Zustand for client state, React Query for server state + +## Features + +- User registration and JWT-based authentication +- Virtual wallet with simulated deposits +- Create custom bets with categories, odds, and stakes +- Browse bet marketplace with filtering +- Accept bets with automatic escrow +- Settle bets with winner/loser confirmation +- Transaction history +- Real-time WebSocket updates +- Responsive UI + +## Quick Start with Docker + +```bash +# Start all services +docker-compose up --build + +# The application will be available at: +# - Frontend: http://localhost:5173 +# - Backend API: http://localhost:8000 +# - API Docs: http://localhost:8000/docs +``` + +## Manual Setup + +### Backend + +```bash +cd backend + +# Create virtual environment +python -m venv venv +source venv/bin/activate # Windows: venv\Scripts\activate + +# Install dependencies +pip install -r requirements.txt + +# Create .env file +cp .env.example .env + +# Initialize database and seed data +python seed_data.py + +# Run development server +uvicorn app.main:app --reload +``` + +### Frontend + +```bash +cd frontend + +# Install dependencies +npm install + +# Create .env file +cp .env.example .env + +# Run development server +npm run dev +``` + +## Test Users + +After running `seed_data.py`, you can login with: + +- **Email**: alice@example.com | **Password**: password123 +- **Email**: bob@example.com | **Password**: password123 +- **Email**: charlie@example.com | **Password**: password123 + +Each user starts with $1000 balance. + +## API Documentation + +Once the backend is running, visit: +- Swagger UI: http://localhost:8000/docs +- ReDoc: http://localhost:8000/redoc + +## Project Structure + +``` +h2h-prototype/ +├── backend/ +│ ├── app/ +│ │ ├── models/ # SQLAlchemy models +│ │ ├── schemas/ # Pydantic schemas +│ │ ├── routers/ # API endpoints +│ │ ├── services/ # Business logic +│ │ ├── crud/ # Database operations +│ │ ├── utils/ # Security, exceptions +│ │ ├── config.py # Configuration +│ │ ├── database.py # Database setup +│ │ └── main.py # FastAPI app +│ ├── data/ # SQLite database +│ ├── seed_data.py # Test data script +│ └── requirements.txt +├── frontend/ +│ ├── src/ +│ │ ├── api/ # API client +│ │ ├── components/ # React components +│ │ ├── pages/ # Page components +│ │ ├── store/ # Zustand store +│ │ ├── types/ # TypeScript types +│ │ ├── utils/ # Utilities +│ │ ├── App.tsx # Main app +│ │ └── main.tsx # Entry point +│ ├── package.json +│ └── vite.config.ts +└── docker-compose.yml +``` + +## Core Workflows + +### 1. Create a Bet +- User creates bet with stake, odds, and positions +- No funds are locked until bet is accepted +- Bet appears in marketplace + +### 2. Accept a Bet +- Opponent accepts bet +- Funds from both parties locked in escrow +- Bet status changes to "matched" + +### 3. Settle a Bet +- Either participant can declare winner +- Winner receives both stakes +- Funds released from escrow +- User stats updated + +## Migration to PostgreSQL + +To migrate from SQLite to PostgreSQL: + +1. Update `DATABASE_URL` in backend `.env`: + ``` + DATABASE_URL=postgresql+asyncpg://user:pass@host:5432/h2h + ``` + +2. Update `requirements.txt`: + - Remove: `aiosqlite` + - Add: `asyncpg` + +3. Run migrations (SQLAlchemy handles the rest) + +## Development + +### Backend Testing + +```bash +cd backend +pytest +``` + +### Frontend Build + +```bash +cd frontend +npm run build +``` + +## MVP Scope + +**In Scope**: +- User auth with JWT +- Virtual wallet with simulated deposits +- Bet creation, acceptance, and settlement +- Basic escrow mechanism +- WebSocket real-time updates +- Responsive UI + +**Out of Scope** (Future Enhancements): +- Real payment processing +- KYC/AML verification +- Blockchain integration +- Automated odds feeds +- Admin panel +- Email notifications +- Social features + +## License + +MIT diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..882a037 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,5 @@ +DATABASE_URL=sqlite+aiosqlite:///./data/h2h.db +JWT_SECRET=your-secret-key-change-in-production-min-32-chars +JWT_ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=30 +REFRESH_TOKEN_EXPIRE_DAYS=7 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..bbe0891 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Install dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY . . + +# Create data directory +RUN mkdir -p data + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/blockchain/__init__.py b/backend/app/blockchain/__init__.py new file mode 100644 index 0000000..f0269c2 --- /dev/null +++ b/backend/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/app/blockchain/config.py b/backend/app/blockchain/config.py new file mode 100644 index 0000000..0b3a705 --- /dev/null +++ b/backend/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/app/blockchain/contracts/BetEscrow.pseudocode.md b/backend/app/blockchain/contracts/BetEscrow.pseudocode.md new file mode 100644 index 0000000..8682101 --- /dev/null +++ b/backend/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/app/blockchain/contracts/BetOracle.pseudocode.md b/backend/app/blockchain/contracts/BetOracle.pseudocode.md new file mode 100644 index 0000000..fa96c02 --- /dev/null +++ b/backend/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/app/blockchain/contracts/README.md b/backend/app/blockchain/contracts/README.md new file mode 100644 index 0000000..577e756 --- /dev/null +++ b/backend/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/app/blockchain/services/__init__.py b/backend/app/blockchain/services/__init__.py new file mode 100644 index 0000000..8fe1af7 --- /dev/null +++ b/backend/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/app/blockchain/services/blockchain_indexer.py b/backend/app/blockchain/services/blockchain_indexer.py new file mode 100644 index 0000000..e78314c --- /dev/null +++ b/backend/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/app/blockchain/services/blockchain_service.py b/backend/app/blockchain/services/blockchain_service.py new file mode 100644 index 0000000..847201a --- /dev/null +++ b/backend/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/app/blockchain/services/oracle_aggregator.py b/backend/app/blockchain/services/oracle_aggregator.py new file mode 100644 index 0000000..f5d3349 --- /dev/null +++ b/backend/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/app/blockchain/services/oracle_node.py b/backend/app/blockchain/services/oracle_node.py new file mode 100644 index 0000000..eeafaa4 --- /dev/null +++ b/backend/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/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..42765c4 --- /dev/null +++ b/backend/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/app/crud/__init__.py b/backend/app/crud/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/crud/bet.py b/backend/app/crud/bet.py new file mode 100644 index 0000000..2ea8121 --- /dev/null +++ b/backend/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/app/crud/user.py b/backend/app/crud/user.py new file mode 100644 index 0000000..64d9e0d --- /dev/null +++ b/backend/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/app/crud/wallet.py b/backend/app/crud/wallet.py new file mode 100644 index 0000000..7945b02 --- /dev/null +++ b/backend/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/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..819f6ac --- /dev/null +++ b/backend/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/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..49ed2a9 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,47 @@ +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 + + +@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.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/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..037e4dd --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,19 @@ +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 + +__all__ = [ + "User", + "UserStatus", + "Wallet", + "Transaction", + "TransactionType", + "TransactionStatus", + "Bet", + "BetProposal", + "BetCategory", + "BetStatus", + "BetVisibility", + "ProposalStatus", +] diff --git a/backend/app/models/bet.py b/backend/app/models/bet.py new file mode 100644 index 0000000..6104ff0 --- /dev/null +++ b/backend/app/models/bet.py @@ -0,0 +1,103 @@ +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) + + # Blockchain integration + blockchain_bet_id: Mapped[int | None] = mapped_column(nullable=True) + blockchain_tx_hash: Mapped[str | None] = mapped_column(String(66), nullable=True) + blockchain_status: Mapped[str | None] = mapped_column(String(20), nullable=True) + + # 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/app/models/transaction.py b/backend/app/models/transaction.py new file mode 100644 index 0000000..13f623a --- /dev/null +++ b/backend/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/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..ced1bc1 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,41 @@ +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) + + 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") diff --git a/backend/app/models/wallet.py b/backend/app/models/wallet.py new file mode 100644 index 0000000..c078f7d --- /dev/null +++ b/backend/app/models/wallet.py @@ -0,0 +1,22 @@ +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")) + blockchain_escrow: Mapped[Decimal] = mapped_column(Numeric(18, 8), default=Decimal("0.00000000")) + 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/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..7784a40 --- /dev/null +++ b/backend/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/app/routers/bets.py b/backend/app/routers/bets.py new file mode 100644 index 0000000..7909537 --- /dev/null +++ b/backend/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/app/routers/users.py b/backend/app/routers/users.py new file mode 100644 index 0000000..7e8687f --- /dev/null +++ b/backend/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/app/routers/wallet.py b/backend/app/routers/wallet.py new file mode 100644 index 0000000..b3e4a55 --- /dev/null +++ b/backend/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/app/routers/websocket.py b/backend/app/routers/websocket.py new file mode 100644 index 0000000..ba9c385 --- /dev/null +++ b/backend/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/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..0f367bf --- /dev/null +++ b/backend/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/app/schemas/bet.py b/backend/app/schemas/bet.py new file mode 100644 index 0000000..f870258 --- /dev/null +++ b/backend/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/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..352f54a --- /dev/null +++ b/backend/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/app/schemas/wallet.py b/backend/app/schemas/wallet.py new file mode 100644 index 0000000..c58d8de --- /dev/null +++ b/backend/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/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py new file mode 100644 index 0000000..623ca9b --- /dev/null +++ b/backend/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/app/services/bet_service.py b/backend/app/services/bet_service.py new file mode 100644 index 0000000..be9bc1c --- /dev/null +++ b/backend/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/app/services/wallet_service.py b/backend/app/services/wallet_service.py new file mode 100644 index 0000000..aec9f24 --- /dev/null +++ b/backend/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/app/utils/__init__.py b/backend/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/utils/exceptions.py b/backend/app/utils/exceptions.py new file mode 100644 index 0000000..bf30b28 --- /dev/null +++ b/backend/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/app/utils/security.py b/backend/app/utils/security.py new file mode 100644 index 0000000..f1c35d9 --- /dev/null +++ b/backend/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/data/.gitkeep b/backend/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..939ffc1 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,13 @@ +fastapi==0.109.0 +uvicorn[standard]==0.27.0 +sqlalchemy[asyncio]==2.0.25 +aiosqlite==0.19.0 +pydantic==2.5.3 +pydantic-settings==2.1.0 +python-jose[cryptography]==3.3.0 +passlib==1.7.4 +bcrypt==4.0.1 +python-multipart==0.0.6 +websockets==12.0 +alembic==1.13.1 +httpx==0.26.0 diff --git a/backend/seed_data.py b/backend/seed_data.py new file mode 100644 index 0000000..809a9a3 --- /dev/null +++ b/backend/seed_data.py @@ -0,0 +1,125 @@ +import asyncio +from decimal import Decimal +from datetime import datetime, timedelta +from app.database import async_session, init_db +from app.models import User, Wallet, Bet, BetCategory, BetStatus, BetVisibility +from app.utils.security import get_password_hash + + +async def seed(): + print("Initializing database...") + await init_db() + + async with async_session() as db: + print("Creating test users...") + users = [ + User( + email="alice@example.com", + username="alice", + password_hash=get_password_hash("password123"), + display_name="Alice Smith" + ), + User( + email="bob@example.com", + username="bob", + password_hash=get_password_hash("password123"), + display_name="Bob Jones" + ), + User( + email="charlie@example.com", + username="charlie", + password_hash=get_password_hash("password123"), + display_name="Charlie Brown" + ), + ] + + for user in users: + db.add(user) + await db.flush() + + print("Creating wallets...") + for user in users: + wallet = Wallet( + user_id=user.id, + balance=Decimal("1000.00"), + escrow=Decimal("0.00") + ) + db.add(wallet) + + print("Creating sample bets...") + bets = [ + Bet( + creator_id=users[0].id, + title="Super Bowl LVIII Winner", + description="Bet on who will win Super Bowl LVIII", + category=BetCategory.SPORTS, + event_name="Super Bowl LVIII", + event_date=datetime.utcnow() + timedelta(days=30), + creator_position="Kansas City Chiefs win", + opponent_position="San Francisco 49ers win", + stake_amount=Decimal("100.00"), + creator_odds=1.5, + opponent_odds=2.0, + visibility=BetVisibility.PUBLIC, + expires_at=datetime.utcnow() + timedelta(days=7) + ), + Bet( + creator_id=users[1].id, + title="League of Legends Worlds 2024", + description="Who will win the LoL World Championship?", + category=BetCategory.ESPORTS, + event_name="LoL Worlds 2024", + creator_position="T1 wins", + opponent_position="Any other team wins", + stake_amount=Decimal("50.00"), + creator_odds=1.8, + opponent_odds=1.8, + visibility=BetVisibility.PUBLIC, + expires_at=datetime.utcnow() + timedelta(days=14) + ), + Bet( + creator_id=users[2].id, + title="Oscar Best Picture 2024", + description="Which film will win Best Picture at the 2024 Oscars?", + category=BetCategory.ENTERTAINMENT, + event_name="96th Academy Awards", + event_date=datetime.utcnow() + timedelta(days=60), + creator_position="Oppenheimer wins", + opponent_position="Any other film wins", + stake_amount=Decimal("25.00"), + creator_odds=1.3, + opponent_odds=3.0, + visibility=BetVisibility.PUBLIC, + expires_at=datetime.utcnow() + timedelta(days=30) + ), + Bet( + creator_id=users[0].id, + title="Bitcoin Price Prediction", + description="Will Bitcoin reach $100,000 by end of Q1 2024?", + category=BetCategory.CUSTOM, + event_name="Bitcoin Price Target", + event_date=datetime.utcnow() + timedelta(days=90), + creator_position="Bitcoin will reach $100k", + opponent_position="Bitcoin won't reach $100k", + stake_amount=Decimal("75.00"), + creator_odds=2.5, + opponent_odds=1.5, + visibility=BetVisibility.PUBLIC, + expires_at=datetime.utcnow() + timedelta(days=20) + ), + ] + + for bet in bets: + db.add(bet) + + await db.commit() + print("✅ Seed data created successfully!") + print("\nTest users:") + print(" - alice@example.com / password123") + print(" - bob@example.com / password123") + print(" - charlie@example.com / password123") + print("\nEach user has $1000 starting balance") + + +if __name__ == "__main__": + asyncio.run(seed()) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..15e9669 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,34 @@ +version: '3.8' + +services: + backend: + build: ./backend + ports: + - "8000:8000" + environment: + - DATABASE_URL=sqlite+aiosqlite:///./data/h2h.db + - JWT_SECRET=${JWT_SECRET:-your-secret-key-change-in-production-min-32-characters} + - JWT_ALGORITHM=HS256 + - ACCESS_TOKEN_EXPIRE_MINUTES=30 + - REFRESH_TOKEN_EXPIRE_DAYS=7 + volumes: + - ./backend:/app + - sqlite_data:/app/data + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + + frontend: + build: ./frontend + ports: + - "5173:5173" + environment: + - VITE_API_URL=http://localhost:8000 + - VITE_WS_URL=ws://localhost:8000 + volumes: + - ./frontend:/app + - /app/node_modules + command: npm run dev -- --host + depends_on: + - backend + +volumes: + sqlite_data: diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..67ff4b1 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,2 @@ +VITE_API_URL=http://localhost:8000 +VITE_WS_URL=ws://localhost:8000 diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..4b1f1ce --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,12 @@ +FROM node:18-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm install + +COPY . . + +EXPOSE 5173 + +CMD ["npm", "run", "dev"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..ca2e7f1 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + H2H - Peer-to-Peer Betting Platform + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..57dd073 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3003 @@ +{ + "name": "h2h-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "h2h-frontend", + "version": "1.0.0", + "dependencies": { + "@tanstack/react-query": "^5.17.0", + "axios": "^1.6.5", + "date-fns": "^3.2.0", + "lucide-react": "^0.303.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.21.0", + "zustand": "^4.4.7" + }, + "devDependencies": { + "@types/react": "^18.2.48", + "@types/react-dom": "^18.2.18", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.17", + "postcss": "^8.4.33", + "tailwindcss": "^3.4.1", + "typescript": "^5.3.3", + "vite": "^5.0.12" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.1", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.1.tgz", + "integrity": "sha512-vDbaOzF7yT2Qs4vO6XV1MHcJv+3dgR1sT+l3B8xxOVhUC336prMvqrvsLL/9Dnw2xr6Qhz4J0dmS0llNAbnUmQ==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz", + "integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz", + "integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz", + "integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz", + "integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz", + "integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz", + "integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz", + "integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz", + "integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz", + "integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz", + "integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz", + "integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz", + "integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz", + "integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz", + "integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz", + "integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz", + "integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz", + "integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz", + "integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz", + "integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz", + "integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz", + "integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz", + "integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tanstack/query-core": { + "version": "5.90.16", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.16.tgz", + "integrity": "sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.16", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.16.tgz", + "integrity": "sha512-bpMGOmV4OPmif7TNMteU/Ehf/hoC0Kf98PDc0F4BZkFrEapRMEqI/V6YS0lyzwSV6PQpY1y4xxArUIfBW5LVxQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.27", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", + "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.23", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz", + "integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001760", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.11", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", + "integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001762", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001762.tgz", + "integrity": "sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/date-fns": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.303.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.303.0.tgz", + "integrity": "sha512-B0B9T3dLEFBYPCUlnUS1mvAhW1craSbF9HO+JfBjAtpFUJ7gMIqmEwNSclikY3RiN2OnCkj/V1ReAQpaHae8Bg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.2.tgz", + "integrity": "sha512-H2Bm38Zu1bm8KUE5NVWRMzuIyAV8p/JrOaBJAwVmp37AXG72+CZJlEBw6pdn9i5TBgLMhNDgijS4ZlblpHyWTA==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.2.tgz", + "integrity": "sha512-l2OwHn3UUnEVUqc6/1VMmR1cvZryZ3j3NzapC2eUXO1dB0sYp5mvwdjiXhpUbRb21eFow3qSxpP8Yv6oAU824Q==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.1", + "react-router": "6.30.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz", + "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.54.0", + "@rollup/rollup-android-arm64": "4.54.0", + "@rollup/rollup-darwin-arm64": "4.54.0", + "@rollup/rollup-darwin-x64": "4.54.0", + "@rollup/rollup-freebsd-arm64": "4.54.0", + "@rollup/rollup-freebsd-x64": "4.54.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", + "@rollup/rollup-linux-arm-musleabihf": "4.54.0", + "@rollup/rollup-linux-arm64-gnu": "4.54.0", + "@rollup/rollup-linux-arm64-musl": "4.54.0", + "@rollup/rollup-linux-loong64-gnu": "4.54.0", + "@rollup/rollup-linux-ppc64-gnu": "4.54.0", + "@rollup/rollup-linux-riscv64-gnu": "4.54.0", + "@rollup/rollup-linux-riscv64-musl": "4.54.0", + "@rollup/rollup-linux-s390x-gnu": "4.54.0", + "@rollup/rollup-linux-x64-gnu": "4.54.0", + "@rollup/rollup-linux-x64-musl": "4.54.0", + "@rollup/rollup-openharmony-arm64": "4.54.0", + "@rollup/rollup-win32-arm64-msvc": "4.54.0", + "@rollup/rollup-win32-ia32-msvc": "4.54.0", + "@rollup/rollup-win32-x64-gnu": "4.54.0", + "@rollup/rollup-win32-x64-msvc": "4.54.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..81e6c09 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,31 @@ +{ + "name": "h2h-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.21.0", + "@tanstack/react-query": "^5.17.0", + "zustand": "^4.4.7", + "axios": "^1.6.5", + "date-fns": "^3.2.0", + "lucide-react": "^0.303.0" + }, + "devDependencies": { + "@types/react": "^18.2.48", + "@types/react-dom": "^18.2.18", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.17", + "postcss": "^8.4.33", + "tailwindcss": "^3.4.1", + "typescript": "^5.3.3", + "vite": "^5.0.12" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..250e399 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,89 @@ +import { useEffect } from 'react' +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { useAuthStore } from './store' +import { Home } from './pages/Home' +import { Login } from './pages/Login' +import { Register } from './pages/Register' +import { Dashboard } from './pages/Dashboard' +import { BetMarketplace } from './pages/BetMarketplace' +import { BetDetails } from './pages/BetDetails' +import { MyBets } from './pages/MyBets' +import { Wallet } from './pages/Wallet' + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: 1, + }, + }, +}) + +function PrivateRoute({ children }: { children: React.ReactNode }) { + const { isAuthenticated } = useAuthStore() + return isAuthenticated ? <>{children} : +} + +function App() { + const { loadUser } = useAuthStore() + + useEffect(() => { + loadUser() + }, [loadUser]) + + return ( + + + + } /> + } /> + } /> + + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + ) +} + +export default App diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts new file mode 100644 index 0000000..0c1e40b --- /dev/null +++ b/frontend/src/api/auth.ts @@ -0,0 +1,24 @@ +import { apiClient } from './client' +import type { User, LoginData, RegisterData, TokenResponse } from '@/types' + +export const authApi = { + register: async (data: RegisterData): Promise => { + const response = await apiClient.post('/api/v1/auth/register', data) + return response.data + }, + + login: async (data: LoginData): Promise => { + const response = await apiClient.post('/api/v1/auth/login', data) + return response.data + }, + + getCurrentUser: async (): Promise => { + const response = await apiClient.get('/api/v1/auth/me') + return response.data + }, + + refreshToken: async (refreshToken: string): Promise => { + const response = await apiClient.post('/api/v1/auth/refresh', { token: refreshToken }) + return response.data + }, +} diff --git a/frontend/src/api/bets.ts b/frontend/src/api/bets.ts new file mode 100644 index 0000000..cc0f2f9 --- /dev/null +++ b/frontend/src/api/bets.ts @@ -0,0 +1,53 @@ +import { apiClient } from './client' +import type { Bet, BetDetail, CreateBetData, BetCategory } from '@/types' + +export const betsApi = { + getBets: async (params?: { skip?: number; limit?: number; category?: BetCategory }): Promise => { + const response = await apiClient.get('/api/v1/bets', { params }) + return response.data + }, + + getBet: async (id: number): Promise => { + const response = await apiClient.get(`/api/v1/bets/${id}`) + return response.data + }, + + createBet: async (data: CreateBetData): Promise => { + const response = await apiClient.post('/api/v1/bets', data) + return response.data + }, + + acceptBet: async (id: number): Promise => { + const response = await apiClient.post(`/api/v1/bets/${id}/accept`) + return response.data + }, + + settleBet: async (id: number, winnerId: number): Promise => { + const response = await apiClient.post(`/api/v1/bets/${id}/settle`, { winner_id: winnerId }) + return response.data + }, + + cancelBet: async (id: number): Promise => { + await apiClient.delete(`/api/v1/bets/${id}`) + }, + + getMyCreatedBets: async (): Promise => { + const response = await apiClient.get('/api/v1/bets/my/created') + return response.data + }, + + getMyAcceptedBets: async (): Promise => { + const response = await apiClient.get('/api/v1/bets/my/accepted') + return response.data + }, + + getMyActiveBets: async (): Promise => { + const response = await apiClient.get('/api/v1/bets/my/active') + return response.data + }, + + getMyHistory: async (): Promise => { + const response = await apiClient.get('/api/v1/bets/my/history') + return response.data + }, +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..89da1d8 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,32 @@ +import axios from 'axios' +import { API_URL } from '@/utils/constants' + +export const apiClient = axios.create({ + baseURL: API_URL, + headers: { + 'Content-Type': 'application/json', + }, +}) + +apiClient.interceptors.request.use( + (config) => { + const token = localStorage.getItem('access_token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config + }, + (error) => Promise.reject(error) +) + +apiClient.interceptors.response.use( + (response) => response, + async (error) => { + if (error.response?.status === 401) { + localStorage.removeItem('access_token') + localStorage.removeItem('refresh_token') + window.location.href = '/login' + } + return Promise.reject(error) + } +) diff --git a/frontend/src/api/wallet.ts b/frontend/src/api/wallet.ts new file mode 100644 index 0000000..51fe1dd --- /dev/null +++ b/frontend/src/api/wallet.ts @@ -0,0 +1,26 @@ +import { apiClient } from './client' +import type { Wallet, Transaction } from '@/types' + +export const walletApi = { + getWallet: async (): Promise => { + const response = await apiClient.get('/api/v1/wallet') + return response.data + }, + + deposit: async (amount: string): Promise => { + const response = await apiClient.post('/api/v1/wallet/deposit', { amount }) + return response.data + }, + + withdraw: async (amount: string): Promise => { + const response = await apiClient.post('/api/v1/wallet/withdraw', { amount }) + return response.data + }, + + getTransactions: async (limit = 50, offset = 0): Promise => { + const response = await apiClient.get('/api/v1/wallet/transactions', { + params: { limit, offset }, + }) + return response.data + }, +} diff --git a/frontend/src/blockchain/components/BlockchainBadge.tsx b/frontend/src/blockchain/components/BlockchainBadge.tsx new file mode 100644 index 0000000..46fdd24 --- /dev/null +++ b/frontend/src/blockchain/components/BlockchainBadge.tsx @@ -0,0 +1,138 @@ +/** + * BlockchainBadge Component + * + * Visual indicator showing that a bet is backed by blockchain. + * + * Features: + * - Shows "On-Chain ⛓️" badge + * - Links to blockchain explorer (Etherscan, Polygonscan) + * - Different states: confirmed, pending, failed + * - Tooltip with transaction details + */ + +import { FC } from 'react' + +interface BlockchainBadgeProps { + status?: 'confirmed' | 'pending' | 'failed' + txHash?: string + chainId?: number + variant?: 'default' | 'compact' +} + +export const BlockchainBadge: FC = ({ + status = 'confirmed', + txHash, + chainId = 11155111, // Sepolia default + variant = 'default' +}) => { + /** + * Get block explorer URL based on chain ID + */ + const getExplorerUrl = (hash: string) => { + const explorers: Record = { + 1: 'https://etherscan.io', + 11155111: 'https://sepolia.etherscan.io', + 137: 'https://polygonscan.com', + 80001: 'https://mumbai.polygonscan.com' + } + + const baseUrl = explorers[chainId] || explorers[11155111] + return `${baseUrl}/tx/${hash}` + } + + /** + * Render badge based on status + */ + if (status === 'pending') { + return ( + + + + + + {variant === 'compact' ? 'Pending' : 'Transaction Pending'} + + ) + } + + if (status === 'failed') { + return ( + + + + + Failed + + ) + } + + // Confirmed status + return ( +
+ + {variant === 'compact' ? ( + <> + ⛓️ + + ) : ( + <> + + + + On-Chain ⛓️ + + )} + + + {txHash && ( + e.stopPropagation()} // Prevent parent click events + > + + + + {variant === 'compact' ? 'View' : 'View on Explorer'} + + )} +
+ ) +} + +/** + * Compact version for use in lists + */ +export const BlockchainBadgeCompact: FC> = (props) => { + return +} + +/** + * Truncate transaction hash for display + */ +export const truncateHash = (hash: string, startChars = 6, endChars = 4): string => { + if (hash.length <= startChars + endChars) { + return hash + } + return `${hash.substring(0, startChars)}...${hash.substring(hash.length - endChars)}` +} + +/** + * Badge with transaction hash display + */ +export const BlockchainBadgeWithHash: FC = (props) => { + if (!props.txHash) { + return + } + + return ( +
+ + + {truncateHash(props.txHash)} + +
+ ) +} diff --git a/frontend/src/blockchain/components/TransactionModal.tsx b/frontend/src/blockchain/components/TransactionModal.tsx new file mode 100644 index 0000000..305b499 --- /dev/null +++ b/frontend/src/blockchain/components/TransactionModal.tsx @@ -0,0 +1,289 @@ +/** + * TransactionModal Component + * + * Modal that shows transaction progress during blockchain operations. + * + * States: + * - Pending: Waiting for user to sign in MetaMask + * - Confirming: Transaction submitted, waiting for block confirmation + * - Success: Transaction confirmed + * - Error: Transaction failed + * + * Features: + * - Animated loading states + * - Transaction hash with explorer link + * - Retry button on error + * - Auto-close on success (optional) + */ + +import { FC, useEffect } from 'react' +import { Modal } from '@/components/common/Modal' + +interface TransactionModalProps { + isOpen: boolean + status: 'idle' | 'pending' | 'confirming' | 'success' | 'error' + message?: string + txHash?: string | null + chainId?: number + onClose: () => void + onRetry?: () => void + autoCloseOnSuccess?: boolean + autoCloseDelay?: number // milliseconds +} + +export const TransactionModal: FC = ({ + isOpen, + status, + message, + txHash, + chainId = 11155111, + onClose, + onRetry, + autoCloseOnSuccess = false, + autoCloseDelay = 3000 +}) => { + /** + * Auto-close on success + */ + useEffect(() => { + if (status === 'success' && autoCloseOnSuccess) { + const timer = setTimeout(() => { + onClose() + }, autoCloseDelay) + + return () => clearTimeout(timer) + } + }, [status, autoCloseOnSuccess, autoCloseDelay, onClose]) + + /** + * Get block explorer URL + */ + const getExplorerUrl = (hash: string) => { + const explorers: Record = { + 1: 'https://etherscan.io', + 11155111: 'https://sepolia.etherscan.io', + 137: 'https://polygonscan.com', + 80001: 'https://mumbai.polygonscan.com' + } + + const baseUrl = explorers[chainId] || explorers[11155111] + return `${baseUrl}/tx/${hash}` + } + + /** + * Render content based on status + */ + const renderContent = () => { + switch (status) { + case 'pending': + return ( +
+
+ + + +
+ +

+ Confirm Transaction +

+ +

+ {message || 'Please confirm the transaction in your wallet'} +

+ +
+ Waiting for wallet confirmation... +
+
+ ) + + case 'confirming': + return ( +
+
+ + + + +
+ +

+ Confirming Transaction +

+ +

+ {message || 'Your transaction is being processed on the blockchain'} +

+ +
+
+
+ Waiting for block confirmation... +
+ + {txHash && ( + + + + + View on Explorer + + )} +
+ +

+ Do not close this window +

+
+ ) + + case 'success': + return ( +
+
+ + + +
+ +

+ Transaction Confirmed! +

+ +

+ {message || 'Your transaction has been successfully processed'} +

+ + {txHash && ( +
+
Transaction Hash
+ + {txHash} + + + + + + View on Block Explorer + +
+ )} + + +
+ ) + + case 'error': + return ( +
+
+ + + +
+ +

+ Transaction Failed +

+ +

+ {message || 'Your transaction could not be processed'} +

+ +
+ {onRetry && ( + + )} + +
+
+ ) + + default: + return null + } + } + + return ( + {} : onClose} // Prevent closing during confirmation + title="" + > + {renderContent()} + + ) +} + +/** + * Compact transaction status indicator (not a modal) + */ +export const TransactionStatusIndicator: FC<{ + status: TransactionModalProps['status'] + txHash?: string + chainId?: number +}> = ({ status, txHash, chainId }) => { + const getExplorerUrl = (hash: string) => { + const explorers: Record = { + 1: 'https://etherscan.io', + 11155111: 'https://sepolia.etherscan.io', + 137: 'https://polygonscan.com', + 80001: 'https://mumbai.polygonscan.com' + } + return `${explorers[chainId || 11155111]}/tx/${hash}` + } + + if (status === 'idle') return null + + const statusConfig = { + pending: { color: 'yellow', icon: '⏳', text: 'Pending' }, + confirming: { color: 'blue', icon: '🔄', text: 'Confirming' }, + success: { color: 'green', icon: '✓', text: 'Confirmed' }, + error: { color: 'red', icon: '✗', text: 'Failed' } + } + + const config = statusConfig[status] + + return ( +
+ {config.icon} + {config.text} + {txHash && status !== 'pending' && ( + + View + + )} +
+ ) +} diff --git a/frontend/src/blockchain/hooks/useBlockchainBet.ts b/frontend/src/blockchain/hooks/useBlockchainBet.ts new file mode 100644 index 0000000..1d2972c --- /dev/null +++ b/frontend/src/blockchain/hooks/useBlockchainBet.ts @@ -0,0 +1,310 @@ +/** + * useBlockchainBet Hook + * + * Handles bet-related blockchain transactions. + * + * Features: + * - Create bet on blockchain + * - Accept bet (user signs with MetaMask) + * - Request oracle settlement + * - Monitor transaction status + * + * NOTE: This is pseudocode/skeleton showing the architecture. + * In production, you would use ethers.js or web3.js with the contract ABIs. + */ + +import { useState, useCallback } from 'react' +import { useWeb3Wallet } from './useWeb3Wallet' +import type { CreateBetData } from '@/types' + +interface TransactionStatus { + hash: string | null + status: 'idle' | 'pending' | 'confirming' | 'success' | 'error' + error: string | null +} + +interface UseBlockchainBetReturn { + createBet: (betData: CreateBetData) => Promise<{ betId: number; txHash: string } | null> + acceptBet: (betId: number, stakeAmount: number) => Promise + settleBet: (betId: number, winnerId: number) => Promise + txStatus: TransactionStatus + isProcessing: boolean +} + +export const useBlockchainBet = (): UseBlockchainBetReturn => { + const { walletAddress, isConnected } = useWeb3Wallet() + const [txStatus, setTxStatus] = useState({ + hash: null, + status: 'idle', + error: null + }) + + /** + * Create a bet on the blockchain + * + * Flow: + * 1. Create bet in backend database (gets local ID) + * 2. User signs transaction to create bet on blockchain + * 3. Wait for confirmation + * 4. Update backend with blockchain bet ID and tx hash + */ + const createBet = useCallback(async (betData: CreateBetData) => { + if (!isConnected || !walletAddress) { + setTxStatus({ + hash: null, + status: 'error', + error: 'Please connect your wallet first' + }) + return null + } + + setTxStatus({ hash: null, status: 'pending', error: null }) + + try { + // Step 1: Create bet in backend (for metadata storage) + // Pseudocode: + // const backendResponse = await fetch('/api/v1/bets', { + // method: 'POST', + // headers: { 'Content-Type': 'application/json' }, + // body: JSON.stringify(betData) + // }) + // const { id: localBetId } = await backendResponse.json() + + const localBetId = 123 // Placeholder + + // Step 2: Create bet on blockchain + setTxStatus(prev => ({ ...prev, status: 'confirming' })) + + // Pseudocode: Call smart contract + // const contract = new ethers.Contract( + // BET_ESCROW_ADDRESS, + // BET_ESCROW_ABI, + // signer + // ) + + // const tx = await contract.createBet( + // ethers.utils.parseEther(betData.stake_amount.toString()), + // Math.floor(betData.creator_odds! * 100), + // Math.floor(betData.opponent_odds! * 100), + // Math.floor(new Date(betData.event_date!).getTime() / 1000), + // ethers.utils.formatBytes32String(betData.event_name) + // ) + + // setTxStatus(prev => ({ ...prev, hash: tx.hash })) + + // // Wait for confirmation + // const receipt = await tx.wait() + + // // Parse BetCreated event to get blockchain bet ID + // const event = receipt.events?.find(e => e.event === 'BetCreated') + // const blockchainBetId = event?.args?.betId.toNumber() + + // Placeholder for pseudocode + const mockTxHash = '0xabc123def456...' + const blockchainBetId = 42 + + setTxStatus({ + hash: mockTxHash, + status: 'success', + error: null + }) + + // Step 3: Update backend with blockchain data + // await fetch(`/api/v1/bets/${localBetId}`, { + // method: 'PATCH', + // headers: { 'Content-Type': 'application/json' }, + // body: JSON.stringify({ + // blockchain_bet_id: blockchainBetId, + // blockchain_tx_hash: tx.hash + // }) + // }) + + console.log('[Blockchain] Bet created:', { + localBetId, + blockchainBetId, + txHash: mockTxHash + }) + + return { + betId: blockchainBetId, + txHash: mockTxHash + } + } catch (error: any) { + console.error('[Blockchain] Create bet failed:', error) + setTxStatus({ + hash: null, + status: 'error', + error: error.message || 'Transaction failed' + }) + return null + } + }, [isConnected, walletAddress]) + + /** + * Accept a bet on the blockchain + * + * Flow: + * 1. User signs transaction with stake amount + * 2. Smart contract locks both parties' funds + * 3. Wait for confirmation + * 4. Backend indexer picks up BetMatched event + */ + const acceptBet = useCallback(async (betId: number, stakeAmount: number) => { + if (!isConnected || !walletAddress) { + setTxStatus({ + hash: null, + status: 'error', + error: 'Please connect your wallet first' + }) + return null + } + + setTxStatus({ hash: null, status: 'pending', error: null }) + + try { + // Pseudocode: Call smart contract + // const contract = new ethers.Contract( + // BET_ESCROW_ADDRESS, + // BET_ESCROW_ABI, + // signer + // ) + + // const tx = await contract.acceptBet(betId, { + // value: ethers.utils.parseEther(stakeAmount.toString()) + // }) + + // setTxStatus(prev => ({ ...prev, hash: tx.hash, status: 'confirming' })) + + // // Wait for confirmation + // await tx.wait() + + // Placeholder for pseudocode + const mockTxHash = '0xdef456ghi789...' + + setTxStatus({ + hash: mockTxHash, + status: 'confirming', + error: null + }) + + // Simulate confirmation delay + await new Promise(resolve => setTimeout(resolve, 2000)) + + setTxStatus({ + hash: mockTxHash, + status: 'success', + error: null + }) + + console.log('[Blockchain] Bet accepted:', { + betId, + stakeAmount, + txHash: mockTxHash + }) + + // Notify backend + // await fetch(`/api/v1/bets/${betId}/accept`, { + // method: 'POST', + // headers: { 'Content-Type': 'application/json' }, + // body: JSON.stringify({ tx_hash: tx.hash }) + // }) + + return mockTxHash + } catch (error: any) { + console.error('[Blockchain] Accept bet failed:', error) + + // Handle user rejection + if (error.code === 4001) { + setTxStatus({ + hash: null, + status: 'error', + error: 'Transaction rejected by user' + }) + } else { + setTxStatus({ + hash: null, + status: 'error', + error: error.message || 'Transaction failed' + }) + } + return null + } + }, [isConnected, walletAddress]) + + /** + * Request oracle settlement for a bet + * + * This is typically called automatically after the event ends, + * but can also be manually triggered by participants. + */ + const settleBet = useCallback(async (betId: number, winnerId: number) => { + if (!isConnected || !walletAddress) { + setTxStatus({ + hash: null, + status: 'error', + error: 'Please connect your wallet first' + }) + return null + } + + setTxStatus({ hash: null, status: 'pending', error: null }) + + try { + // For automatic settlement, backend calls the oracle + // For manual settlement (after timeout), users can call directly + + // Pseudocode: Request settlement via backend + // const response = await fetch(`/api/v1/bets/${betId}/request-settlement`, { + // method: 'POST', + // headers: { 'Content-Type': 'application/json' }, + // body: JSON.stringify({ winner_id: winnerId }) + // }) + + // const { request_id } = await response.json() + + // Placeholder for pseudocode + const mockRequestId = 7 + + setTxStatus({ + hash: null, + status: 'confirming', + error: null + }) + + console.log('[Blockchain] Settlement requested:', { + betId, + winnerId, + requestId: mockRequestId + }) + + // Settlement happens asynchronously via oracle network + // Frontend can poll for status or listen to WebSocket events + + setTxStatus({ + hash: null, + status: 'success', + error: null + }) + + return `request-${mockRequestId}` + } catch (error: any) { + console.error('[Blockchain] Settle bet failed:', error) + setTxStatus({ + hash: null, + status: 'error', + error: error.message || 'Settlement request failed' + }) + return null + } + }, [isConnected, walletAddress]) + + const isProcessing = txStatus.status === 'pending' || txStatus.status === 'confirming' + + return { + createBet, + acceptBet, + settleBet, + txStatus, + isProcessing + } +} diff --git a/frontend/src/blockchain/hooks/useGasEstimate.ts b/frontend/src/blockchain/hooks/useGasEstimate.ts new file mode 100644 index 0000000..30cf5d2 --- /dev/null +++ b/frontend/src/blockchain/hooks/useGasEstimate.ts @@ -0,0 +1,191 @@ +/** + * useGasEstimate Hook + * + * Estimates gas costs for blockchain transactions before execution. + * + * Features: + * - Fetch current gas price + * - Estimate gas limit for specific operations + * - Calculate total cost in ETH and USD + * - Real-time gas price updates + * + * NOTE: This is pseudocode/skeleton showing the architecture. + */ + +import { useState, useEffect, useCallback } from 'react' +import { useWeb3Wallet } from './useWeb3Wallet' + +interface GasEstimate { + gasLimit: number + gasPrice: number // in wei + gasPriceGwei: number + costEth: string + costUsd: string + isLoading: boolean + error: string | null +} + +interface UseGasEstimateReturn extends GasEstimate { + refresh: () => Promise +} + +type TransactionType = 'create_bet' | 'accept_bet' | 'settle_bet' | 'dispute_bet' + +/** + * Hook to estimate gas costs for transactions + */ +export const useGasEstimate = ( + transactionType: TransactionType, + params?: any +): UseGasEstimateReturn => { + const { isConnected, chainId } = useWeb3Wallet() + const [estimate, setEstimate] = useState({ + gasLimit: 0, + gasPrice: 0, + gasPriceGwei: 0, + costEth: '0', + costUsd: '0', + isLoading: true, + error: null + }) + + /** + * Fetch gas estimate from backend + */ + const fetchEstimate = useCallback(async () => { + if (!isConnected) { + setEstimate(prev => ({ + ...prev, + isLoading: false, + error: 'Wallet not connected' + })) + return + } + + setEstimate(prev => ({ ...prev, isLoading: true, error: null })) + + try { + // Pseudocode: Call backend API for gas estimate + // const response = await fetch('/api/v1/blockchain/estimate-gas', { + // method: 'POST', + // headers: { 'Content-Type': 'application/json' }, + // body: JSON.stringify({ + // transaction_type: transactionType, + // params: params + // }) + // }) + + // const data = await response.json() + + // setEstimate({ + // gasLimit: data.gas_limit, + // gasPrice: data.gas_price, + // gasPriceGwei: data.gas_price / 1e9, + // costEth: data.cost_eth, + // costUsd: data.cost_usd, + // isLoading: false, + // error: null + // }) + + // Placeholder estimates for different transaction types + const estimates: Record = { + 'create_bet': { gasLimit: 120000, gasPriceGwei: 50 }, + 'accept_bet': { gasLimit: 180000, gasPriceGwei: 50 }, + 'settle_bet': { gasLimit: 150000, gasPriceGwei: 50 }, + 'dispute_bet': { gasLimit: 100000, gasPriceGwei: 50 } + } + + const { gasLimit, gasPriceGwei } = estimates[transactionType] + const gasPrice = gasPriceGwei * 1e9 // Convert to wei + const costWei = gasLimit * gasPrice + const costEth = (costWei / 1e18).toFixed(6) + const ethPriceUsd = 2000 // Placeholder - would fetch from price oracle + const costUsd = (parseFloat(costEth) * ethPriceUsd).toFixed(2) + + setEstimate({ + gasLimit, + gasPrice, + gasPriceGwei, + costEth, + costUsd, + isLoading: false, + error: null + }) + + console.log('[Gas] Estimate for', transactionType, ':', { costEth, costUsd }) + } catch (error: any) { + console.error('[Gas] Failed to fetch estimate:', error) + setEstimate(prev => ({ + ...prev, + isLoading: false, + error: 'Failed to estimate gas' + })) + } + }, [transactionType, params, isConnected]) + + /** + * Fetch estimate on mount and when params change + */ + useEffect(() => { + fetchEstimate() + }, [fetchEstimate]) + + /** + * Refresh estimate every 30 seconds (gas prices change frequently) + */ + useEffect(() => { + if (!isConnected) return + + const interval = setInterval(() => { + fetchEstimate() + }, 30000) // 30 seconds + + return () => clearInterval(interval) + }, [fetchEstimate, isConnected]) + + return { + ...estimate, + refresh: fetchEstimate + } +} + +/** + * Hook to get current gas price (without specific transaction estimate) + */ +export const useGasPrice = () => { + const [gasPrice, setGasPrice] = useState<{ + gwei: number + wei: number + isLoading: boolean + }>({ + gwei: 0, + wei: 0, + isLoading: true + }) + + useEffect(() => { + const fetchGasPrice = async () => { + // Pseudocode: Fetch from RPC + // const provider = new ethers.providers.Web3Provider(window.ethereum) + // const price = await provider.getGasPrice() + // const gwei = parseFloat(ethers.utils.formatUnits(price, 'gwei')) + + // Placeholder + const gwei = 50 + const wei = gwei * 1e9 + + setGasPrice({ + gwei, + wei, + isLoading: false + }) + } + + fetchGasPrice() + + const interval = setInterval(fetchGasPrice, 30000) + return () => clearInterval(interval) + }, []) + + return gasPrice +} diff --git a/frontend/src/blockchain/hooks/useWeb3Wallet.ts b/frontend/src/blockchain/hooks/useWeb3Wallet.ts new file mode 100644 index 0000000..10494d1 --- /dev/null +++ b/frontend/src/blockchain/hooks/useWeb3Wallet.ts @@ -0,0 +1,323 @@ +/** + * useWeb3Wallet Hook + * + * Manages MetaMask wallet connection and account state. + * + * Features: + * - Connect/disconnect wallet + * - Listen for account changes + * - Listen for network changes + * - Link wallet address to backend user account + * + * NOTE: This is pseudocode/skeleton showing the architecture. + * In production, you would use wagmi, ethers.js, or web3.js libraries. + */ + +import { useState, useEffect, useCallback } from 'react' + +interface Web3WalletState { + walletAddress: string | null + chainId: number | null + isConnected: boolean + isConnecting: boolean + error: string | null +} + +interface Web3WalletHook extends Web3WalletState { + connectWallet: () => Promise + disconnectWallet: () => void + switchNetwork: (chainId: number) => Promise +} + +// Declare window.ethereum type +declare global { + interface Window { + ethereum?: { + request: (args: { method: string; params?: any[] }) => Promise + on: (event: string, callback: (...args: any[]) => void) => void + removeListener: (event: string, callback: (...args: any[]) => void) => void + selectedAddress: string | null + } + } +} + +export const useWeb3Wallet = (): Web3WalletHook => { + const [state, setState] = useState({ + walletAddress: null, + chainId: null, + isConnected: false, + isConnecting: false, + error: null, + }) + + /** + * Check if MetaMask is installed + */ + const isMetaMaskInstalled = useCallback(() => { + return typeof window !== 'undefined' && typeof window.ethereum !== 'undefined' + }, []) + + /** + * Connect to MetaMask wallet + */ + const connectWallet = useCallback(async () => { + if (!isMetaMaskInstalled()) { + setState(prev => ({ + ...prev, + error: 'Please install MetaMask to use blockchain features' + })) + return + } + + setState(prev => ({ ...prev, isConnecting: true, error: null })) + + try { + // Pseudocode: Request account access + // const accounts = await window.ethereum!.request({ + // method: 'eth_requestAccounts' + // }) + + // const address = accounts[0] + + // // Get current chain ID + // const chainId = await window.ethereum!.request({ + // method: 'eth_chainId' + // }) + + // setState({ + // walletAddress: address, + // chainId: parseInt(chainId, 16), + // isConnected: true, + // isConnecting: false, + // error: null + // }) + + // // Link wallet to backend user account + // await linkWalletToAccount(address) + + // console.log('Wallet connected:', address) + + // Placeholder for pseudocode + const mockAddress = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb' + const mockChainId = 11155111 // Sepolia + + setState({ + walletAddress: mockAddress, + chainId: mockChainId, + isConnected: true, + isConnecting: false, + error: null + }) + + console.log('[Web3] Wallet connected:', mockAddress) + } catch (error: any) { + console.error('Failed to connect wallet:', error) + setState(prev => ({ + ...prev, + isConnecting: false, + error: error.message || 'Failed to connect wallet' + })) + } + }, [isMetaMaskInstalled]) + + /** + * Disconnect wallet + */ + const disconnectWallet = useCallback(() => { + setState({ + walletAddress: null, + chainId: null, + isConnected: false, + isConnecting: false, + error: null + }) + console.log('[Web3] Wallet disconnected') + }, []) + + /** + * Switch to a different network + */ + const switchNetwork = useCallback(async (targetChainId: number) => { + if (!isMetaMaskInstalled()) { + return + } + + try { + // Pseudocode: Switch network + // await window.ethereum!.request({ + // method: 'wallet_switchEthereumChain', + // params: [{ chainId: `0x${targetChainId.toString(16)}` }] + // }) + + console.log(`[Web3] Switched to chain ID: ${targetChainId}`) + } catch (error: any) { + // If network doesn't exist, add it + if (error.code === 4902) { + console.log('[Web3] Network not added, prompting user to add it') + // Pseudocode: Add network + // await window.ethereum!.request({ + // method: 'wallet_addEthereumChain', + // params: [getNetworkConfig(targetChainId)] + // }) + } + console.error('Failed to switch network:', error) + } + }, [isMetaMaskInstalled]) + + /** + * Link wallet address to backend user account + */ + const linkWalletToAccount = async (address: string) => { + // Pseudocode: Call backend API + // try { + // await fetch('/api/v1/users/link-wallet', { + // method: 'POST', + // headers: { + // 'Content-Type': 'application/json', + // 'Authorization': `Bearer ${getAccessToken()}` + // }, + // body: JSON.stringify({ wallet_address: address }) + // }) + // console.log('[Web3] Wallet linked to user account') + // } catch (error) { + // console.error('[Web3] Failed to link wallet:', error) + // } + + console.log('[Web3] Wallet linked to account:', address) + } + + /** + * Listen for account changes + */ + useEffect(() => { + if (!isMetaMaskInstalled()) { + return + } + + const handleAccountsChanged = (accounts: string[]) => { + if (accounts.length === 0) { + // User disconnected + disconnectWallet() + } else if (accounts[0] !== state.walletAddress) { + // User switched accounts + setState(prev => ({ + ...prev, + walletAddress: accounts[0] + })) + console.log('[Web3] Account changed to:', accounts[0]) + } + } + + // Pseudocode: Add event listener + // window.ethereum!.on('accountsChanged', handleAccountsChanged) + + // return () => { + // window.ethereum!.removeListener('accountsChanged', handleAccountsChanged) + // } + }, [isMetaMaskInstalled, state.walletAddress, disconnectWallet]) + + /** + * Listen for network changes + */ + useEffect(() => { + if (!isMetaMaskInstalled()) { + return + } + + const handleChainChanged = (chainId: string) => { + const newChainId = parseInt(chainId, 16) + setState(prev => ({ + ...prev, + chainId: newChainId + })) + console.log('[Web3] Network changed to chain ID:', newChainId) + + // Refresh page on network change (recommended by MetaMask) + // window.location.reload() + } + + // Pseudocode: Add event listener + // window.ethereum!.on('chainChanged', handleChainChanged) + + // return () => { + // window.ethereum!.removeListener('chainChanged', handleChainChanged) + // } + }, [isMetaMaskInstalled]) + + /** + * Check if already connected on mount + */ + useEffect(() => { + const checkConnection = async () => { + if (!isMetaMaskInstalled()) { + return + } + + // Pseudocode: Check if already connected + // const accounts = await window.ethereum!.request({ + // method: 'eth_accounts' + // }) + + // if (accounts.length > 0) { + // const chainId = await window.ethereum!.request({ + // method: 'eth_chainId' + // }) + + // setState({ + // walletAddress: accounts[0], + // chainId: parseInt(chainId, 16), + // isConnected: true, + // isConnecting: false, + // error: null + // }) + // } + } + + checkConnection() + }, [isMetaMaskInstalled]) + + return { + ...state, + connectWallet, + disconnectWallet, + switchNetwork, + } +} + +/** + * Network configuration for wallet_addEthereumChain + */ +const getNetworkConfig = (chainId: number) => { + const configs: Record = { + 1: { + chainId: '0x1', + chainName: 'Ethereum Mainnet', + nativeCurrency: { name: 'ETH', symbol: 'ETH', decimals: 18 }, + rpcUrls: ['https://eth-mainnet.alchemyapi.io/v2/YOUR-API-KEY'], + blockExplorerUrls: ['https://etherscan.io'] + }, + 11155111: { + chainId: '0xaa36a7', + chainName: 'Sepolia Testnet', + nativeCurrency: { name: 'ETH', symbol: 'ETH', decimals: 18 }, + rpcUrls: ['https://rpc.sepolia.org'], + blockExplorerUrls: ['https://sepolia.etherscan.io'] + }, + 137: { + chainId: '0x89', + chainName: 'Polygon Mainnet', + nativeCurrency: { name: 'MATIC', symbol: 'MATIC', decimals: 18 }, + rpcUrls: ['https://polygon-rpc.com'], + blockExplorerUrls: ['https://polygonscan.com'] + }, + 80001: { + chainId: '0x13881', + chainName: 'Polygon Mumbai Testnet', + nativeCurrency: { name: 'MATIC', symbol: 'MATIC', decimals: 18 }, + rpcUrls: ['https://rpc-mumbai.maticvigil.com'], + blockExplorerUrls: ['https://mumbai.polygonscan.com'] + } + } + + return configs[chainId] +} diff --git a/frontend/src/components/auth/LoginForm.tsx b/frontend/src/components/auth/LoginForm.tsx new file mode 100644 index 0000000..894b246 --- /dev/null +++ b/frontend/src/components/auth/LoginForm.tsx @@ -0,0 +1,70 @@ +import { useState, FormEvent } from 'react' +import { useNavigate } from 'react-router-dom' +import { useAuthStore } from '@/store' +import { Button } from '@/components/common/Button' + +export const LoginForm = () => { + const navigate = useNavigate() + const { login } = useAuthStore() + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const [isLoading, setIsLoading] = useState(false) + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault() + setError('') + setIsLoading(true) + + try { + await login(email, password) + navigate('/dashboard') + } catch (err: any) { + setError(err.response?.data?.detail || 'Login failed. Please try again.') + } finally { + setIsLoading(false) + } + } + + return ( +
+ {error && ( +
+ {error} +
+ )} + +
+ + setEmail(e.target.value)} + required + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" + /> +
+ +
+ + setPassword(e.target.value)} + required + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" + /> +
+ + +
+ ) +} diff --git a/frontend/src/components/auth/RegisterForm.tsx b/frontend/src/components/auth/RegisterForm.tsx new file mode 100644 index 0000000..bf53623 --- /dev/null +++ b/frontend/src/components/auth/RegisterForm.tsx @@ -0,0 +1,101 @@ +import { useState, FormEvent } from 'react' +import { useNavigate } from 'react-router-dom' +import { useAuthStore } from '@/store' +import { Button } from '@/components/common/Button' + +export const RegisterForm = () => { + const navigate = useNavigate() + const { register } = useAuthStore() + const [email, setEmail] = useState('') + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [displayName, setDisplayName] = useState('') + const [error, setError] = useState('') + const [isLoading, setIsLoading] = useState(false) + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault() + setError('') + setIsLoading(true) + + try { + await register(email, username, password, displayName || undefined) + navigate('/dashboard') + } catch (err: any) { + setError(err.response?.data?.detail || 'Registration failed. Please try again.') + } finally { + setIsLoading(false) + } + } + + return ( +
+ {error && ( +
+ {error} +
+ )} + +
+ + setEmail(e.target.value)} + required + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" + /> +
+ +
+ + setUsername(e.target.value)} + required + minLength={3} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" + /> +
+ +
+ + setDisplayName(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" + /> +
+ +
+ + setPassword(e.target.value)} + required + minLength={8} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" + /> +
+ + +
+ ) +} diff --git a/frontend/src/components/bets/BetCard.tsx b/frontend/src/components/bets/BetCard.tsx new file mode 100644 index 0000000..2b4ee73 --- /dev/null +++ b/frontend/src/components/bets/BetCard.tsx @@ -0,0 +1,66 @@ +import { Link } from 'react-router-dom' +import type { Bet } from '@/types' +import { Card } from '@/components/common/Card' +import { formatCurrency, formatRelativeTime } from '@/utils/formatters' +import { BET_CATEGORIES, BET_STATUS_COLORS } from '@/utils/constants' +import { Calendar, DollarSign, TrendingUp } from 'lucide-react' +import { BlockchainBadgeCompact } from '@/blockchain/components/BlockchainBadge' + +interface BetCardProps { + bet: Bet +} + +export const BetCard = ({ bet }: BetCardProps) => { + return ( + + +
+
+

{bet.title}

+
+ + {bet.status} + + {bet.blockchain_tx_hash && ( + + )} +
+
+ +

{bet.description}

+ +
+
+ + {bet.event_name} +
+ + {BET_CATEGORIES[bet.category]} + +
+ +
+
+
+ + {formatCurrency(bet.stake_amount)} +
+
+ + {bet.creator_odds}x / {bet.opponent_odds}x +
+
+ +
+ Created by {bet.creator.display_name || bet.creator.username} + {formatRelativeTime(bet.created_at)} +
+
+
+
+ + ) +} diff --git a/frontend/src/components/bets/BetList.tsx b/frontend/src/components/bets/BetList.tsx new file mode 100644 index 0000000..33160b8 --- /dev/null +++ b/frontend/src/components/bets/BetList.tsx @@ -0,0 +1,24 @@ +import type { Bet } from '@/types' +import { BetCard } from './BetCard' + +interface BetListProps { + bets: Bet[] +} + +export const BetList = ({ bets }: BetListProps) => { + if (bets.length === 0) { + return ( +
+ No bets found +
+ ) + } + + return ( +
+ {bets.map((bet) => ( + + ))} +
+ ) +} diff --git a/frontend/src/components/bets/CreateBetModal.tsx b/frontend/src/components/bets/CreateBetModal.tsx new file mode 100644 index 0000000..f9a88c8 --- /dev/null +++ b/frontend/src/components/bets/CreateBetModal.tsx @@ -0,0 +1,272 @@ +import { useState, FormEvent } from 'react' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { betsApi } from '@/api/bets' +import { Modal } from '@/components/common/Modal' +import { Button } from '@/components/common/Button' +import type { BetCategory } from '@/types' +import { BET_CATEGORIES } from '@/utils/constants' +import { useBlockchainBet } from '@/blockchain/hooks/useBlockchainBet' +import { useGasEstimate } from '@/blockchain/hooks/useGasEstimate' +import { TransactionModal } from '@/blockchain/components/TransactionModal' + +interface CreateBetModalProps { + isOpen: boolean + onClose: () => void +} + +export const CreateBetModal = ({ isOpen, onClose }: CreateBetModalProps) => { + const queryClient = useQueryClient() + const [formData, setFormData] = useState({ + title: '', + description: '', + category: 'sports' as BetCategory, + event_name: '', + creator_position: '', + opponent_position: '', + stake_amount: '', + creator_odds: '1', + opponent_odds: '1', + }) + + // Blockchain integration + const { createBet: createBlockchainBet, txStatus, txHash } = useBlockchainBet() + const gasEstimate = useGasEstimate( + 'create_bet', + { stakeAmount: parseFloat(formData.stake_amount) || 0 } + ) + + const createMutation = useMutation({ + mutationFn: betsApi.createBet, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['bets'] }) + onClose() + setFormData({ + title: '', + description: '', + category: 'sports', + event_name: '', + creator_position: '', + opponent_position: '', + stake_amount: '', + creator_odds: '1', + opponent_odds: '1', + }) + }, + }) + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault() + + const betData = { + ...formData, + stake_amount: parseFloat(formData.stake_amount), + creator_odds: parseFloat(formData.creator_odds), + opponent_odds: parseFloat(formData.opponent_odds), + } + + // Create bet with blockchain integration + // This will: 1) Create in backend, 2) Sign transaction, 3) Update with blockchain ID + try { + await createBlockchainBet(betData) + queryClient.invalidateQueries({ queryKey: ['bets'] }) + onClose() + setFormData({ + title: '', + description: '', + category: 'sports', + event_name: '', + creator_position: '', + opponent_position: '', + stake_amount: '', + creator_odds: '1', + opponent_odds: '1', + }) + } catch (error) { + console.error('Failed to create bet:', error) + } + } + + return ( + +
+
+ + setFormData({ ...formData, title: e.target.value })} + required + maxLength={200} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" + /> +
+ +
+ +