This commit is contained in:
2026-01-02 10:43:20 -06:00
commit 14d9af3036
112 changed files with 14274 additions and 0 deletions

60
.gitignore vendored Normal file
View File

@ -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/

View File

@ -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 │
│ │
│ <BlockchainBadge /> ─> "On-Chain ⛓️" indicator │
│ <TransactionModal />─> 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 <BlockchainBadge>
- [ ] 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

216
CLAUDE.md Normal file
View File

@ -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

View File

@ -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<void>;
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 <repo>
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.

196
README.md Normal file
View File

@ -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

5
backend/.env.example Normal file
View File

@ -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

17
backend/Dockerfile Normal file
View File

@ -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"]

0
backend/app/__init__.py Normal file
View File

View File

@ -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",
]

View File

@ -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
"""

View File

@ -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;
}
```

View File

@ -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

View File

@ -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.

View File

@ -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",
]

View File

@ -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()

View File

@ -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

View File

@ -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
"""

View File

@ -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()

14
backend/app/config.py Normal file
View File

@ -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()

View File

79
backend/app/crud/bet.py Normal file
View File

@ -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

56
backend/app/crud/user.py Normal file
View File

@ -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()

View File

@ -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

38
backend/app/database.py Normal file
View File

@ -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)

47
backend/app/main.py Normal file
View File

@ -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"}

View File

@ -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",
]

103
backend/app/models/bet.py Normal file
View File

@ -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")

View File

@ -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")

View File

@ -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")

View File

@ -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")

View File

View File

@ -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,
)

173
backend/app/routers/bets.py Normal file
View File

@ -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

View File

@ -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,
)

View File

@ -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

View File

@ -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)

View File

@ -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",
]

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

View File

@ -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,
)

View File

@ -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

View File

@ -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

View File

View File

@ -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")

View File

@ -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])

0
backend/data/.gitkeep Normal file
View File

13
backend/requirements.txt Normal file
View File

@ -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

125
backend/seed_data.py Normal file
View File

@ -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())

34
docker-compose.yml Normal file
View File

@ -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:

2
frontend/.env.example Normal file
View File

@ -0,0 +1,2 @@
VITE_API_URL=http://localhost:8000
VITE_WS_URL=ws://localhost:8000

12
frontend/Dockerfile Normal file
View File

@ -0,0 +1,12 @@
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev"]

13
frontend/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>H2H - Peer-to-Peer Betting Platform</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3003
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
frontend/package.json Normal file
View File

@ -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"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

89
frontend/src/App.tsx Normal file
View File

@ -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}</> : <Navigate to="/login" />
}
function App() {
const { loadUser } = useAuthStore()
useEffect(() => {
loadUser()
}, [loadUser])
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route
path="/dashboard"
element={
<PrivateRoute>
<Dashboard />
</PrivateRoute>
}
/>
<Route
path="/marketplace"
element={
<PrivateRoute>
<BetMarketplace />
</PrivateRoute>
}
/>
<Route
path="/bets/:id"
element={
<PrivateRoute>
<BetDetails />
</PrivateRoute>
}
/>
<Route
path="/my-bets"
element={
<PrivateRoute>
<MyBets />
</PrivateRoute>
}
/>
<Route
path="/wallet"
element={
<PrivateRoute>
<Wallet />
</PrivateRoute>
}
/>
</Routes>
</BrowserRouter>
</QueryClientProvider>
)
}
export default App

24
frontend/src/api/auth.ts Normal file
View File

@ -0,0 +1,24 @@
import { apiClient } from './client'
import type { User, LoginData, RegisterData, TokenResponse } from '@/types'
export const authApi = {
register: async (data: RegisterData): Promise<TokenResponse> => {
const response = await apiClient.post<TokenResponse>('/api/v1/auth/register', data)
return response.data
},
login: async (data: LoginData): Promise<TokenResponse> => {
const response = await apiClient.post<TokenResponse>('/api/v1/auth/login', data)
return response.data
},
getCurrentUser: async (): Promise<User> => {
const response = await apiClient.get<User>('/api/v1/auth/me')
return response.data
},
refreshToken: async (refreshToken: string): Promise<TokenResponse> => {
const response = await apiClient.post<TokenResponse>('/api/v1/auth/refresh', { token: refreshToken })
return response.data
},
}

53
frontend/src/api/bets.ts Normal file
View File

@ -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<Bet[]> => {
const response = await apiClient.get<Bet[]>('/api/v1/bets', { params })
return response.data
},
getBet: async (id: number): Promise<BetDetail> => {
const response = await apiClient.get<BetDetail>(`/api/v1/bets/${id}`)
return response.data
},
createBet: async (data: CreateBetData): Promise<Bet> => {
const response = await apiClient.post<Bet>('/api/v1/bets', data)
return response.data
},
acceptBet: async (id: number): Promise<Bet> => {
const response = await apiClient.post<Bet>(`/api/v1/bets/${id}/accept`)
return response.data
},
settleBet: async (id: number, winnerId: number): Promise<BetDetail> => {
const response = await apiClient.post<BetDetail>(`/api/v1/bets/${id}/settle`, { winner_id: winnerId })
return response.data
},
cancelBet: async (id: number): Promise<void> => {
await apiClient.delete(`/api/v1/bets/${id}`)
},
getMyCreatedBets: async (): Promise<Bet[]> => {
const response = await apiClient.get<Bet[]>('/api/v1/bets/my/created')
return response.data
},
getMyAcceptedBets: async (): Promise<Bet[]> => {
const response = await apiClient.get<Bet[]>('/api/v1/bets/my/accepted')
return response.data
},
getMyActiveBets: async (): Promise<Bet[]> => {
const response = await apiClient.get<Bet[]>('/api/v1/bets/my/active')
return response.data
},
getMyHistory: async (): Promise<BetDetail[]> => {
const response = await apiClient.get<BetDetail[]>('/api/v1/bets/my/history')
return response.data
},
}

View File

@ -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)
}
)

View File

@ -0,0 +1,26 @@
import { apiClient } from './client'
import type { Wallet, Transaction } from '@/types'
export const walletApi = {
getWallet: async (): Promise<Wallet> => {
const response = await apiClient.get<Wallet>('/api/v1/wallet')
return response.data
},
deposit: async (amount: string): Promise<Wallet> => {
const response = await apiClient.post<Wallet>('/api/v1/wallet/deposit', { amount })
return response.data
},
withdraw: async (amount: string): Promise<Wallet> => {
const response = await apiClient.post<Wallet>('/api/v1/wallet/withdraw', { amount })
return response.data
},
getTransactions: async (limit = 50, offset = 0): Promise<Transaction[]> => {
const response = await apiClient.get<Transaction[]>('/api/v1/wallet/transactions', {
params: { limit, offset },
})
return response.data
},
}

View File

@ -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<BlockchainBadgeProps> = ({
status = 'confirmed',
txHash,
chainId = 11155111, // Sepolia default
variant = 'default'
}) => {
/**
* Get block explorer URL based on chain ID
*/
const getExplorerUrl = (hash: string) => {
const explorers: Record<number, string> = {
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 (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 border border-yellow-300">
<svg className="animate-spin h-3 w-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{variant === 'compact' ? 'Pending' : 'Transaction Pending'}
</span>
)
}
if (status === 'failed') {
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800 border border-red-300">
<svg className="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
Failed
</span>
)
}
// Confirmed status
return (
<div className="inline-flex items-center gap-1.5">
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 border border-green-300">
{variant === 'compact' ? (
<>
<span></span>
</>
) : (
<>
<svg className="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
<span>On-Chain </span>
</>
)}
</span>
{txHash && (
<a
href={getExplorerUrl(txHash)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800 underline"
onClick={(e) => e.stopPropagation()} // Prevent parent click events
>
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
{variant === 'compact' ? 'View' : 'View on Explorer'}
</a>
)}
</div>
)
}
/**
* Compact version for use in lists
*/
export const BlockchainBadgeCompact: FC<Omit<BlockchainBadgeProps, 'variant'>> = (props) => {
return <BlockchainBadge {...props} variant="compact" />
}
/**
* 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<BlockchainBadgeProps> = (props) => {
if (!props.txHash) {
return <BlockchainBadge {...props} />
}
return (
<div className="inline-flex items-center gap-2">
<BlockchainBadge {...props} />
<code className="text-xs text-gray-600 bg-gray-100 px-2 py-0.5 rounded font-mono">
{truncateHash(props.txHash)}
</code>
</div>
)
}

View File

@ -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<TransactionModalProps> = ({
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<number, string> = {
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 (
<div className="text-center py-8">
<div className="inline-flex items-center justify-center w-16 h-16 mb-4 rounded-full bg-blue-100">
<svg className="w-8 h-8 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Confirm Transaction
</h3>
<p className="text-sm text-gray-600 mb-4">
{message || 'Please confirm the transaction in your wallet'}
</p>
<div className="text-xs text-gray-500">
Waiting for wallet confirmation...
</div>
</div>
)
case 'confirming':
return (
<div className="text-center py-8">
<div className="inline-flex items-center justify-center w-16 h-16 mb-4">
<svg className="animate-spin h-12 w-12 text-blue-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Confirming Transaction
</h3>
<p className="text-sm text-gray-600 mb-4">
{message || 'Your transaction is being processed on the blockchain'}
</p>
<div className="space-y-2">
<div className="flex items-center justify-center gap-2 text-xs text-gray-600">
<div className="w-2 h-2 bg-blue-600 rounded-full animate-pulse"></div>
<span>Waiting for block confirmation...</span>
</div>
{txHash && (
<a
href={getExplorerUrl(txHash)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800 underline"
>
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
View on Explorer
</a>
)}
</div>
<p className="text-xs text-gray-500 mt-4">
Do not close this window
</p>
</div>
)
case 'success':
return (
<div className="text-center py-8">
<div className="inline-flex items-center justify-center w-16 h-16 mb-4 rounded-full bg-green-100">
<svg className="w-10 h-10 text-green-600" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Transaction Confirmed!
</h3>
<p className="text-sm text-gray-600 mb-4">
{message || 'Your transaction has been successfully processed'}
</p>
{txHash && (
<div className="space-y-2 mb-6">
<div className="text-xs text-gray-500">Transaction Hash</div>
<code className="block text-xs bg-gray-100 px-3 py-2 rounded font-mono break-all">
{txHash}
</code>
<a
href={getExplorerUrl(txHash)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-800 underline"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
View on Block Explorer
</a>
</div>
)}
<button
onClick={onClose}
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
Close
</button>
</div>
)
case 'error':
return (
<div className="text-center py-8">
<div className="inline-flex items-center justify-center w-16 h-16 mb-4 rounded-full bg-red-100">
<svg className="w-10 h-10 text-red-600" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Transaction Failed
</h3>
<p className="text-sm text-gray-600 mb-6">
{message || 'Your transaction could not be processed'}
</p>
<div className="flex gap-3 justify-center">
{onRetry && (
<button
onClick={onRetry}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Try Again
</button>
)}
<button
onClick={onClose}
className="px-6 py-2 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300 transition-colors"
>
Close
</button>
</div>
</div>
)
default:
return null
}
}
return (
<Modal
isOpen={isOpen && status !== 'idle'}
onClose={status === 'confirming' ? () => {} : onClose} // Prevent closing during confirmation
title=""
>
{renderContent()}
</Modal>
)
}
/**
* 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<number, string> = {
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 (
<div className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-${config.color}-100 text-${config.color}-800`}>
<span>{config.icon}</span>
<span className="text-sm font-medium">{config.text}</span>
{txHash && status !== 'pending' && (
<a
href={getExplorerUrl(txHash)}
target="_blank"
rel="noopener noreferrer"
className="text-xs underline"
>
View
</a>
)}
</div>
)
}

View File

@ -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<string | null>
settleBet: (betId: number, winnerId: number) => Promise<string | null>
txStatus: TransactionStatus
isProcessing: boolean
}
export const useBlockchainBet = (): UseBlockchainBetReturn => {
const { walletAddress, isConnected } = useWeb3Wallet()
const [txStatus, setTxStatus] = useState<TransactionStatus>({
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
}
}

View File

@ -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<void>
}
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<GasEstimate>({
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<TransactionType, { gasLimit: number; gasPriceGwei: number }> = {
'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
}

View File

@ -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<void>
disconnectWallet: () => void
switchNetwork: (chainId: number) => Promise<void>
}
// Declare window.ethereum type
declare global {
interface Window {
ethereum?: {
request: (args: { method: string; params?: any[] }) => Promise<any>
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<Web3WalletState>({
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<number, any> = {
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]
}

View File

@ -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 (
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="bg-error/10 border border-error text-error px-4 py-3 rounded">
{error}
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => 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"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => 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"
/>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Logging in...' : 'Login'}
</Button>
</form>
)
}

View File

@ -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 (
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="bg-error/10 border border-error text-error px-4 py-3 rounded">
{error}
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => 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"
/>
</div>
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1">
Username
</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => 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"
/>
</div>
<div>
<label htmlFor="displayName" className="block text-sm font-medium text-gray-700 mb-1">
Display Name (optional)
</label>
<input
id="displayName"
type="text"
value={displayName}
onChange={(e) => 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"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => 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"
/>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Registering...' : 'Register'}
</Button>
</form>
)
}

View File

@ -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 (
<Link to={`/bets/${bet.id}`}>
<Card className="hover:shadow-lg transition-shadow cursor-pointer">
<div className="space-y-3">
<div className="flex items-start justify-between">
<h3 className="text-lg font-semibold text-gray-900">{bet.title}</h3>
<div className="flex items-center gap-2">
<span className={`px-2 py-1 rounded text-xs font-medium ${BET_STATUS_COLORS[bet.status]}`}>
{bet.status}
</span>
{bet.blockchain_tx_hash && (
<BlockchainBadgeCompact
status="confirmed"
txHash={bet.blockchain_tx_hash}
/>
)}
</div>
</div>
<p className="text-sm text-gray-600 line-clamp-2">{bet.description}</p>
<div className="flex items-center gap-4 text-sm text-gray-500">
<div className="flex items-center gap-1">
<Calendar size={16} />
<span>{bet.event_name}</span>
</div>
<span className="px-2 py-0.5 bg-gray-100 rounded text-xs">
{BET_CATEGORIES[bet.category]}
</span>
</div>
<div className="pt-3 border-t space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-primary font-semibold">
<DollarSign size={18} />
{formatCurrency(bet.stake_amount)}
</div>
<div className="flex items-center gap-2 text-sm text-gray-600">
<TrendingUp size={16} />
<span>{bet.creator_odds}x / {bet.opponent_odds}x</span>
</div>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-gray-600">Created by {bet.creator.display_name || bet.creator.username}</span>
<span className="text-gray-500">{formatRelativeTime(bet.created_at)}</span>
</div>
</div>
</div>
</Card>
</Link>
)
}

View File

@ -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 (
<div className="text-center py-12 text-gray-500">
No bets found
</div>
)
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{bets.map((bet) => (
<BetCard key={bet.id} bet={bet} />
))}
</div>
)
}

View File

@ -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 (
<Modal isOpen={isOpen} onClose={onClose} title="Create New Bet">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Title
</label>
<input
type="text"
value={formData.title}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
required
rows={3}
maxLength={2000}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Category
</label>
<select
value={formData.category}
onChange={(e) => setFormData({ ...formData, category: e.target.value as BetCategory })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
>
{Object.entries(BET_CATEGORIES).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Event Name
</label>
<input
type="text"
value={formData.event_name}
onChange={(e) => setFormData({ ...formData, event_name: 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"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Your Position
</label>
<input
type="text"
value={formData.creator_position}
onChange={(e) => setFormData({ ...formData, creator_position: e.target.value })}
required
maxLength={500}
placeholder="What are you betting on?"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Opponent Position
</label>
<input
type="text"
value={formData.opponent_position}
onChange={(e) => setFormData({ ...formData, opponent_position: e.target.value })}
required
maxLength={500}
placeholder="What is the opposing position?"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Stake Amount ($)
</label>
<input
type="number"
value={formData.stake_amount}
onChange={(e) => setFormData({ ...formData, stake_amount: e.target.value })}
required
min="0.01"
max="10000"
step="0.01"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Your Odds
</label>
<input
type="number"
value={formData.creator_odds}
onChange={(e) => setFormData({ ...formData, creator_odds: e.target.value })}
min="0.01"
step="0.1"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Opponent Odds
</label>
<input
type="number"
value={formData.opponent_odds}
onChange={(e) => setFormData({ ...formData, opponent_odds: e.target.value })}
min="0.01"
step="0.1"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
</div>
{/* Gas Estimate */}
{formData.stake_amount && parseFloat(formData.stake_amount) > 0 && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<div className="text-xs font-medium text-blue-800 mb-1">Estimated Gas Cost</div>
{gasEstimate.isLoading ? (
<div className="text-sm text-blue-600">Calculating...</div>
) : gasEstimate.error ? (
<div className="text-sm text-red-600">{gasEstimate.error}</div>
) : (
<div className="flex items-center justify-between text-sm">
<span className="text-blue-900">{gasEstimate.costEth} ETH</span>
<span className="text-blue-700"> ${gasEstimate.costUsd}</span>
</div>
)}
</div>
)}
<div className="flex gap-4 pt-4">
<Button type="button" variant="secondary" onClick={onClose} className="flex-1">
Cancel
</Button>
<Button type="submit" className="flex-1" disabled={txStatus === 'pending' || txStatus === 'confirming'}>
{txStatus === 'pending' || txStatus === 'confirming' ? 'Processing...' : 'Create Bet'}
</Button>
</div>
</form>
{/* Transaction Status Modal */}
<TransactionModal
isOpen={txStatus !== 'idle'}
status={txStatus}
txHash={txHash}
message={
txStatus === 'success'
? 'Your bet has been created on the blockchain!'
: txStatus === 'error'
? 'Failed to create bet on blockchain'
: undefined
}
onClose={() => {}}
autoCloseOnSuccess={true}
autoCloseDelay={2000}
/>
</Modal>
)
}

View File

@ -0,0 +1,39 @@
import { ButtonHTMLAttributes, ReactNode } from 'react'
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger' | 'success'
size?: 'sm' | 'md' | 'lg'
children: ReactNode
}
export const Button = ({
variant = 'primary',
size = 'md',
className = '',
children,
...props
}: ButtonProps) => {
const baseClasses = 'font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed'
const variants = {
primary: 'bg-primary text-white hover:bg-blue-600',
secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300',
danger: 'bg-error text-white hover:bg-red-600',
success: 'bg-success text-white hover:bg-green-600',
}
const sizes = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg',
}
return (
<button
className={`${baseClasses} ${variants[variant]} ${sizes[size]} ${className}`}
{...props}
>
{children}
</button>
)
}

View File

@ -0,0 +1,14 @@
import { ReactNode } from 'react'
interface CardProps {
children: ReactNode
className?: string
}
export const Card = ({ children, className = '' }: CardProps) => {
return (
<div className={`bg-white rounded-lg shadow-md p-6 ${className}`}>
{children}
</div>
)
}

View File

@ -0,0 +1,7 @@
export const Loading = () => {
return (
<div className="flex items-center justify-center p-8">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div>
)
}

View File

@ -0,0 +1,37 @@
import { ReactNode } from 'react'
import { X } from 'lucide-react'
interface ModalProps {
isOpen: boolean
onClose: () => void
title: string
children: ReactNode
}
export const Modal = ({ isOpen, onClose, title, children }: ModalProps) => {
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex min-h-screen items-center justify-center p-4">
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={onClose} />
<div className="relative bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between p-6 border-b">
<h2 className="text-2xl font-bold">{title}</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<X size={24} />
</button>
</div>
<div className="p-6">
{children}
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,72 @@
import { Link } from 'react-router-dom'
import { useAuthStore } from '@/store'
import { Wallet, LogOut, User } from 'lucide-react'
import { useWeb3Wallet } from '@/blockchain/hooks/useWeb3Wallet'
export const Header = () => {
const { user, logout } = useAuthStore()
const { walletAddress, isConnected, connectWallet, disconnectWallet } = useWeb3Wallet()
return (
<header className="bg-white shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
<Link to="/" className="flex items-center">
<h1 className="text-2xl font-bold text-primary">H2H</h1>
</Link>
{user && (
<nav className="flex items-center gap-6">
<Link to="/dashboard" className="text-gray-700 hover:text-primary transition-colors">
Dashboard
</Link>
<Link to="/marketplace" className="text-gray-700 hover:text-primary transition-colors">
Marketplace
</Link>
<Link to="/my-bets" className="text-gray-700 hover:text-primary transition-colors">
My Bets
</Link>
<Link to="/wallet" className="flex items-center gap-2 text-gray-700 hover:text-primary transition-colors">
<Wallet size={18} />
Wallet
</Link>
{/* Web3 Wallet Connection */}
{isConnected ? (
<button
onClick={disconnectWallet}
className="flex items-center gap-2 px-3 py-1.5 bg-green-100 text-green-800 rounded-lg hover:bg-green-200 transition-colors text-sm font-medium"
>
<span></span>
<span>{walletAddress?.substring(0, 6)}...{walletAddress?.substring(38)}</span>
</button>
) : (
<button
onClick={connectWallet}
className="flex items-center gap-2 px-3 py-1.5 bg-blue-100 text-blue-800 rounded-lg hover:bg-blue-200 transition-colors text-sm font-medium"
>
<span></span>
<span>Connect Wallet</span>
</button>
)}
<div className="flex items-center gap-4 pl-6 border-l">
<Link to="/profile" className="flex items-center gap-2 text-gray-700 hover:text-primary transition-colors">
<User size={18} />
{user.display_name || user.username}
</Link>
<button
onClick={logout}
className="flex items-center gap-2 text-gray-700 hover:text-error transition-colors"
>
<LogOut size={18} />
Logout
</button>
</div>
</nav>
)}
</div>
</div>
</header>
)
}

View File

@ -0,0 +1,17 @@
import { ReactNode } from 'react'
import { Header } from './Header'
interface LayoutProps {
children: ReactNode
}
export const Layout = ({ children }: LayoutProps) => {
return (
<div className="min-h-screen bg-gray-50">
<Header />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{children}
</main>
</div>
)
}

View File

@ -0,0 +1,65 @@
import { useState, FormEvent } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { walletApi } from '@/api/wallet'
import { Modal } from '@/components/common/Modal'
import { Button } from '@/components/common/Button'
interface DepositModalProps {
isOpen: boolean
onClose: () => void
}
export const DepositModal = ({ isOpen, onClose }: DepositModalProps) => {
const queryClient = useQueryClient()
const [amount, setAmount] = useState('')
const depositMutation = useMutation({
mutationFn: walletApi.deposit,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['wallet'] })
queryClient.invalidateQueries({ queryKey: ['transactions'] })
setAmount('')
onClose()
},
})
const handleSubmit = (e: FormEvent) => {
e.preventDefault()
depositMutation.mutate(amount)
}
return (
<Modal isOpen={isOpen} onClose={onClose} title="Deposit Funds">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Amount ($)
</label>
<input
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
required
min="0.01"
max="10000"
step="0.01"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Enter amount"
/>
<p className="mt-1 text-sm text-gray-500">
This is a simulated deposit for MVP testing purposes.
</p>
</div>
<div className="flex gap-4">
<Button type="button" variant="secondary" onClick={onClose} className="flex-1">
Cancel
</Button>
<Button type="submit" className="flex-1" disabled={depositMutation.isPending}>
{depositMutation.isPending ? 'Processing...' : 'Deposit'}
</Button>
</div>
</form>
</Modal>
)
}

View File

@ -0,0 +1,54 @@
import { useQuery } from '@tanstack/react-query'
import { walletApi } from '@/api/wallet'
import { Card } from '@/components/common/Card'
import { formatCurrency, formatDateTime } from '@/utils/formatters'
import { ArrowUpRight, ArrowDownRight } from 'lucide-react'
import { Loading } from '@/components/common/Loading'
export const TransactionHistory = () => {
const { data: transactions, isLoading } = useQuery({
queryKey: ['transactions'],
queryFn: () => walletApi.getTransactions(),
})
if (isLoading) return <Loading />
return (
<Card>
<h2 className="text-xl font-bold mb-4">Transaction History</h2>
<div className="space-y-3">
{transactions && transactions.length > 0 ? (
transactions.map((tx) => (
<div key={tx.id} className="flex items-center justify-between p-4 border rounded-lg hover:bg-gray-50">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-full ${
parseFloat(tx.amount) >= 0 ? 'bg-success/10 text-success' : 'bg-error/10 text-error'
}`}>
{parseFloat(tx.amount) >= 0 ? <ArrowDownRight size={20} /> : <ArrowUpRight size={20} />}
</div>
<div>
<p className="font-medium">{tx.description}</p>
<p className="text-sm text-gray-500">{formatDateTime(tx.created_at)}</p>
</div>
</div>
<div className="text-right">
<p className={`font-semibold ${
parseFloat(tx.amount) >= 0 ? 'text-success' : 'text-error'
}`}>
{parseFloat(tx.amount) >= 0 ? '+' : ''}{formatCurrency(tx.amount)}
</p>
<p className="text-sm text-gray-500">
Balance: {formatCurrency(tx.balance_after)}
</p>
</div>
</div>
))
) : (
<p className="text-center text-gray-500 py-8">No transactions yet</p>
)}
</div>
</Card>
)
}

View File

@ -0,0 +1,94 @@
import { useQuery } from '@tanstack/react-query'
import { walletApi } from '@/api/wallet'
import { Card } from '@/components/common/Card'
import { formatCurrency } from '@/utils/formatters'
import { Wallet, Lock } from 'lucide-react'
import { Loading } from '@/components/common/Loading'
import { useWeb3Wallet } from '@/blockchain/hooks/useWeb3Wallet'
import { BlockchainBadge } from '@/blockchain/components/BlockchainBadge'
export const WalletBalance = () => {
const { data: wallet, isLoading } = useQuery({
queryKey: ['wallet'],
queryFn: walletApi.getWallet,
})
// Blockchain integration
const { walletAddress, isConnected, walletBalance } = useWeb3Wallet()
if (isLoading) return <Loading />
if (!wallet) return null
const totalFunds = parseFloat(wallet.balance) + parseFloat(wallet.escrow)
const onChainEscrow = wallet.blockchain_escrow || 0 // Placeholder for on-chain escrow amount
return (
<Card>
<div className="space-y-4">
<h2 className="text-xl font-bold flex items-center gap-2">
<Wallet size={24} />
Wallet Balance
</h2>
<div className="space-y-3">
<div className="flex justify-between items-center p-4 bg-primary/10 rounded-lg">
<span className="text-gray-700 font-medium">Available Balance</span>
<span className="text-2xl font-bold text-primary">{formatCurrency(wallet.balance)}</span>
</div>
<div className="flex justify-between items-center p-4 bg-warning/10 rounded-lg">
<span className="text-gray-700 font-medium flex items-center gap-2">
<Lock size={18} />
Locked in Escrow
</span>
<span className="text-xl font-semibold text-warning">{formatCurrency(wallet.escrow)}</span>
</div>
<div className="flex justify-between items-center p-4 bg-gray-100 rounded-lg">
<span className="text-gray-700 font-medium">Total Funds</span>
<span className="text-xl font-semibold text-gray-900">{formatCurrency(totalFunds)}</span>
</div>
</div>
{/* On-Chain Escrow Section */}
{isConnected && (
<div className="mt-6 pt-6 border-t">
<div className="flex items-center gap-2 mb-3">
<h3 className="text-lg font-semibold text-gray-900">On-Chain Escrow</h3>
<BlockchainBadge status="confirmed" variant="compact" />
</div>
<div className="space-y-3">
<div className="flex justify-between items-center p-4 bg-green-50 border border-green-200 rounded-lg">
<span className="text-gray-700 font-medium">Locked in Smart Contract</span>
<div className="text-right">
<div className="text-lg font-semibold text-green-800">
{onChainEscrow} ETH
</div>
<div className="text-sm text-green-600">
{formatCurrency(onChainEscrow * 2000)}
</div>
</div>
</div>
<div className="flex justify-between items-center p-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-600">Wallet Balance</span>
<span className="text-sm font-medium text-gray-900">{walletBalance} ETH</span>
</div>
{walletAddress && (
<div className="flex justify-between items-center p-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-600">Wallet Address</span>
<code className="text-xs text-gray-700 bg-gray-200 px-2 py-1 rounded font-mono">
{walletAddress.substring(0, 6)}...{walletAddress.substring(38)}
</code>
</div>
)}
</div>
</div>
)}
</div>
</Card>
)
}

17
frontend/src/index.css Normal file
View File

@ -0,0 +1,17 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

10
frontend/src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@ -0,0 +1,238 @@
import { useParams, useNavigate } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Layout } from '@/components/layout/Layout'
import { Card } from '@/components/common/Card'
import { Button } from '@/components/common/Button'
import { Loading } from '@/components/common/Loading'
import { betsApi } from '@/api/bets'
import { useAuthStore } from '@/store'
import { formatCurrency, formatDateTime } from '@/utils/formatters'
import { BET_CATEGORIES, BET_STATUS_COLORS } from '@/utils/constants'
import { Calendar, User, DollarSign, TrendingUp } from 'lucide-react'
import { useBlockchainBet } from '@/blockchain/hooks/useBlockchainBet'
import { useGasEstimate } from '@/blockchain/hooks/useGasEstimate'
import { BlockchainBadge } from '@/blockchain/components/BlockchainBadge'
import { TransactionModal } from '@/blockchain/components/TransactionModal'
export const BetDetails = () => {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const { user } = useAuthStore()
const queryClient = useQueryClient()
const { data: bet, isLoading } = useQuery({
queryKey: ['bet', id],
queryFn: () => betsApi.getBet(Number(id)),
})
// Blockchain integration
const { acceptBet: acceptBlockchainBet, txStatus, txHash } = useBlockchainBet()
const gasEstimate = useGasEstimate('accept_bet', { stakeAmount: bet?.stake_amount || 0 })
const acceptMutation = useMutation({
mutationFn: () => betsApi.acceptBet(Number(id)),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['bet', id] })
queryClient.invalidateQueries({ queryKey: ['bets'] })
queryClient.invalidateQueries({ queryKey: ['wallet'] })
},
})
const settleMutation = useMutation({
mutationFn: (winnerId: number) => betsApi.settleBet(Number(id), winnerId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['bet', id] })
queryClient.invalidateQueries({ queryKey: ['wallet'] })
},
})
if (isLoading) return <Layout><Loading /></Layout>
if (!bet) return <Layout><div>Bet not found</div></Layout>
const isCreator = user?.id === bet.creator.id
const isOpponent = user?.id === bet.opponent?.id
const isParticipant = isCreator || isOpponent
const canAccept = bet.status === 'open' && !isCreator
const canSettle = bet.status === 'matched' && isParticipant
return (
<Layout>
<div className="max-w-4xl mx-auto space-y-6">
<Button variant="secondary" onClick={() => navigate(-1)}>
Back
</Button>
<Card>
<div className="space-y-6">
<div className="flex justify-between items-start">
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">{bet.title}</h1>
<div className="flex items-center gap-3 text-sm text-gray-600">
<span className="px-2 py-0.5 bg-gray-100 rounded">
{BET_CATEGORIES[bet.category]}
</span>
<span className={`px-2 py-1 rounded text-xs font-medium ${BET_STATUS_COLORS[bet.status]}`}>
{bet.status}
</span>
{bet.blockchain_tx_hash && (
<BlockchainBadge
status="confirmed"
txHash={bet.blockchain_tx_hash}
/>
)}
</div>
</div>
</div>
<p className="text-gray-700 text-lg">{bet.description}</p>
<div className="grid md:grid-cols-2 gap-6 pt-6 border-t">
<div className="space-y-4">
<h3 className="font-semibold text-lg">Bet Details</h3>
<div className="flex items-center gap-3">
<Calendar size={20} className="text-gray-400" />
<div>
<p className="text-sm text-gray-600">Event</p>
<p className="font-medium">{bet.event_name}</p>
</div>
</div>
{bet.event_date && (
<div className="flex items-center gap-3">
<Calendar size={20} className="text-gray-400" />
<div>
<p className="text-sm text-gray-600">Event Date</p>
<p className="font-medium">{formatDateTime(bet.event_date)}</p>
</div>
</div>
)}
<div className="flex items-center gap-3">
<DollarSign size={20} className="text-gray-400" />
<div>
<p className="text-sm text-gray-600">Stake Amount</p>
<p className="font-medium text-xl text-primary">{formatCurrency(bet.stake_amount)}</p>
</div>
</div>
<div className="flex items-center gap-3">
<TrendingUp size={20} className="text-gray-400" />
<div>
<p className="text-sm text-gray-600">Odds</p>
<p className="font-medium">{bet.creator_odds}x / {bet.opponent_odds}x</p>
</div>
</div>
</div>
<div className="space-y-4">
<h3 className="font-semibold text-lg">Participants</h3>
<div className="flex items-center gap-3">
<User size={20} className="text-gray-400" />
<div>
<p className="text-sm text-gray-600">Creator</p>
<p className="font-medium">{bet.creator.display_name || bet.creator.username}</p>
<p className="text-sm text-gray-600 mt-1">Position: {bet.creator_position}</p>
</div>
</div>
<div className="flex items-center gap-3">
<User size={20} className="text-gray-400" />
<div>
<p className="text-sm text-gray-600">Opponent</p>
{bet.opponent ? (
<>
<p className="font-medium">{bet.opponent.display_name || bet.opponent.username}</p>
<p className="text-sm text-gray-600 mt-1">Position: {bet.opponent_position}</p>
</>
) : (
<p className="font-medium text-gray-400">Waiting for opponent...</p>
)}
</div>
</div>
</div>
</div>
{canAccept && (
<div className="pt-6 border-t space-y-4">
{/* Gas Estimate */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="text-sm font-medium text-blue-800 mb-2">Estimated Gas Cost</div>
{gasEstimate.isLoading ? (
<div className="text-sm text-blue-600">Calculating...</div>
) : gasEstimate.error ? (
<div className="text-sm text-red-600">{gasEstimate.error}</div>
) : (
<div className="flex items-center justify-between">
<span className="text-blue-900 font-medium">{gasEstimate.costEth} ETH</span>
<span className="text-blue-700"> ${gasEstimate.costUsd}</span>
</div>
)}
</div>
<Button
onClick={async () => {
try {
await acceptBlockchainBet(Number(id), bet.stake_amount)
queryClient.invalidateQueries({ queryKey: ['bet', id] })
queryClient.invalidateQueries({ queryKey: ['bets'] })
queryClient.invalidateQueries({ queryKey: ['wallet'] })
} catch (error) {
console.error('Failed to accept bet:', error)
}
}}
disabled={txStatus === 'pending' || txStatus === 'confirming'}
className="w-full"
>
{txStatus === 'pending' || txStatus === 'confirming'
? 'Processing...'
: `Accept Bet - ${formatCurrency(bet.stake_amount)}`}
</Button>
</div>
)}
{canSettle && (
<div className="pt-6 border-t space-y-4">
<h3 className="font-semibold">Settle Bet</h3>
<div className="flex gap-4">
<Button
onClick={() => settleMutation.mutate(bet.creator.id)}
disabled={settleMutation.isPending}
className="flex-1"
>
{bet.creator.display_name || bet.creator.username} Won
</Button>
<Button
onClick={() => settleMutation.mutate(bet.opponent!.id)}
disabled={settleMutation.isPending}
className="flex-1"
>
{bet.opponent?.display_name || bet.opponent?.username} Won
</Button>
</div>
</div>
)}
</div>
</Card>
</div>
{/* Transaction Status Modal */}
<TransactionModal
isOpen={txStatus !== 'idle'}
status={txStatus}
txHash={txHash}
message={
txStatus === 'success'
? 'Bet accepted successfully on blockchain!'
: txStatus === 'error'
? 'Failed to accept bet on blockchain'
: undefined
}
onClose={() => {}}
autoCloseOnSuccess={true}
autoCloseDelay={2000}
/>
</Layout>
)
}

View File

@ -0,0 +1,75 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Layout } from '@/components/layout/Layout'
import { BetList } from '@/components/bets/BetList'
import { CreateBetModal } from '@/components/bets/CreateBetModal'
import { Button } from '@/components/common/Button'
import { Loading } from '@/components/common/Loading'
import { betsApi } from '@/api/bets'
import { Plus } from 'lucide-react'
import type { BetCategory } from '@/types'
import { BET_CATEGORIES } from '@/utils/constants'
export const BetMarketplace = () => {
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
const [selectedCategory, setSelectedCategory] = useState<BetCategory | undefined>()
const { data: bets, isLoading } = useQuery({
queryKey: ['bets', selectedCategory],
queryFn: () => betsApi.getBets({ category: selectedCategory }),
})
return (
<Layout>
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900">Bet Marketplace</h1>
<p className="text-gray-600 mt-2">Browse and accept open bets from other users</p>
</div>
<Button onClick={() => setIsCreateModalOpen(true)}>
<Plus size={20} className="mr-2" />
Create Bet
</Button>
</div>
<div className="flex gap-2 flex-wrap">
<button
onClick={() => setSelectedCategory(undefined)}
className={`px-4 py-2 rounded-lg transition-colors ${
selectedCategory === undefined
? 'bg-primary text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
All
</button>
{Object.entries(BET_CATEGORIES).map(([value, label]) => (
<button
key={value}
onClick={() => setSelectedCategory(value as BetCategory)}
className={`px-4 py-2 rounded-lg transition-colors ${
selectedCategory === value
? 'bg-primary text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
{label}
</button>
))}
</div>
{isLoading ? (
<Loading />
) : bets ? (
<BetList bets={bets} />
) : null}
</div>
<CreateBetModal
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
/>
</Layout>
)
}

View File

@ -0,0 +1,112 @@
import { useQuery } from '@tanstack/react-query'
import { Layout } from '@/components/layout/Layout'
import { Card } from '@/components/common/Card'
import { Loading } from '@/components/common/Loading'
import { betsApi } from '@/api/bets'
import { walletApi } from '@/api/wallet'
import { useAuthStore } from '@/store'
import { formatCurrency } from '@/utils/formatters'
import { TrendingUp, Activity, Award, Wallet } from 'lucide-react'
export const Dashboard = () => {
const { user } = useAuthStore()
const { data: wallet } = useQuery({
queryKey: ['wallet'],
queryFn: walletApi.getWallet,
})
const { data: activeBets, isLoading } = useQuery({
queryKey: ['myActiveBets'],
queryFn: betsApi.getMyActiveBets,
})
if (isLoading) return <Layout><Loading /></Layout>
return (
<Layout>
<div className="space-y-8">
<div>
<h1 className="text-3xl font-bold text-gray-900">
Welcome back, {user?.display_name || user?.username}!
</h1>
<p className="text-gray-600 mt-2">Here's an overview of your betting activity</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<Card>
<div className="flex items-center gap-4">
<div className="bg-primary/10 p-3 rounded-lg">
<Wallet className="text-primary" size={24} />
</div>
<div>
<p className="text-sm text-gray-600">Balance</p>
<p className="text-2xl font-bold text-gray-900">
{wallet ? formatCurrency(wallet.balance) : '$0.00'}
</p>
</div>
</div>
</Card>
<Card>
<div className="flex items-center gap-4">
<div className="bg-success/10 p-3 rounded-lg">
<Activity className="text-success" size={24} />
</div>
<div>
<p className="text-sm text-gray-600">Active Bets</p>
<p className="text-2xl font-bold text-gray-900">{activeBets?.length || 0}</p>
</div>
</div>
</Card>
<Card>
<div className="flex items-center gap-4">
<div className="bg-warning/10 p-3 rounded-lg">
<TrendingUp className="text-warning" size={24} />
</div>
<div>
<p className="text-sm text-gray-600">Total Bets</p>
<p className="text-2xl font-bold text-gray-900">{user?.total_bets || 0}</p>
</div>
</div>
</Card>
<Card>
<div className="flex items-center gap-4">
<div className="bg-purple-100 p-3 rounded-lg">
<Award className="text-purple-600" size={24} />
</div>
<div>
<p className="text-sm text-gray-600">Win Rate</p>
<p className="text-2xl font-bold text-gray-900">
{user ? `${(user.win_rate * 100).toFixed(0)}%` : '0%'}
</p>
</div>
</div>
</Card>
</div>
<Card>
<h2 className="text-xl font-bold mb-4">Active Bets</h2>
{activeBets && activeBets.length > 0 ? (
<div className="space-y-3">
{activeBets.map((bet) => (
<div key={bet.id} className="flex justify-between items-center p-4 border rounded-lg">
<div>
<h3 className="font-semibold">{bet.title}</h3>
<p className="text-sm text-gray-600">{bet.event_name}</p>
</div>
<div className="text-right">
<p className="font-semibold text-primary">{formatCurrency(bet.stake_amount)}</p>
<p className="text-sm text-gray-500">{bet.status}</p>
</div>
</div>
))}
</div>
) : (
<p className="text-center text-gray-500 py-8">No active bets. Check out the marketplace!</p>
)}
</Card>
</div>
</Layout>
)
}

View File

@ -0,0 +1,61 @@
import { Link } from 'react-router-dom'
import { Button } from '@/components/common/Button'
import { TrendingUp, Shield, Zap } from 'lucide-react'
export const Home = () => {
return (
<div className="min-h-screen bg-gradient-to-br from-primary/10 to-purple-100">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
<div className="text-center">
<h1 className="text-6xl font-bold text-gray-900 mb-6">
Welcome to <span className="text-primary">H2H</span>
</h1>
<p className="text-xl text-gray-600 mb-8 max-w-2xl mx-auto">
The peer-to-peer betting platform where you create, accept, and settle wagers directly with other users.
</p>
<div className="flex gap-4 justify-center mb-16">
<Link to="/register">
<Button size="lg">Get Started</Button>
</Link>
<Link to="/login">
<Button size="lg" variant="secondary">Login</Button>
</Link>
</div>
</div>
<div className="grid md:grid-cols-3 gap-8 mt-16">
<div className="bg-white p-8 rounded-lg shadow-md text-center">
<div className="bg-primary/10 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4">
<TrendingUp className="text-primary" size={32} />
</div>
<h3 className="text-xl font-semibold mb-3">Create Custom Bets</h3>
<p className="text-gray-600">
Create your own bets on sports, esports, politics, entertainment, or anything else you can imagine.
</p>
</div>
<div className="bg-white p-8 rounded-lg shadow-md text-center">
<div className="bg-success/10 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4">
<Shield className="text-success" size={32} />
</div>
<h3 className="text-xl font-semibold mb-3">Secure Escrow</h3>
<p className="text-gray-600">
Funds are safely locked in escrow when a bet is matched, ensuring fair and secure transactions.
</p>
</div>
<div className="bg-white p-8 rounded-lg shadow-md text-center">
<div className="bg-warning/10 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4">
<Zap className="text-warning" size={32} />
</div>
<h3 className="text-xl font-semibold mb-3">Real-time Updates</h3>
<p className="text-gray-600">
Get instant notifications when your bets are matched, settled, or when new opportunities arise.
</p>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,29 @@
import { Link } from 'react-router-dom'
import { LoginForm } from '@/components/auth/LoginForm'
import { Card } from '@/components/common/Card'
export const Login = () => {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
<div className="max-w-md w-full">
<div className="text-center mb-8">
<h1 className="text-4xl font-bold text-primary mb-2">H2H</h1>
<h2 className="text-2xl font-semibold text-gray-900">Login to your account</h2>
</div>
<Card>
<LoginForm />
<div className="mt-6 text-center">
<p className="text-sm text-gray-600">
Don't have an account?{' '}
<Link to="/register" className="text-primary hover:underline font-medium">
Register here
</Link>
</p>
</div>
</Card>
</div>
</div>
)
}

View File

@ -0,0 +1,78 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Layout } from '@/components/layout/Layout'
import { BetList } from '@/components/bets/BetList'
import { Loading } from '@/components/common/Loading'
import { betsApi } from '@/api/bets'
export const MyBets = () => {
const [activeTab, setActiveTab] = useState<'created' | 'accepted' | 'active' | 'history'>('active')
const { data: createdBets } = useQuery({
queryKey: ['myCreatedBets'],
queryFn: betsApi.getMyCreatedBets,
enabled: activeTab === 'created',
})
const { data: acceptedBets } = useQuery({
queryKey: ['myAcceptedBets'],
queryFn: betsApi.getMyAcceptedBets,
enabled: activeTab === 'accepted',
})
const { data: activeBets } = useQuery({
queryKey: ['myActiveBets'],
queryFn: betsApi.getMyActiveBets,
enabled: activeTab === 'active',
})
const { data: historyBets } = useQuery({
queryKey: ['myHistory'],
queryFn: betsApi.getMyHistory,
enabled: activeTab === 'history',
})
const currentBets =
activeTab === 'created' ? createdBets :
activeTab === 'accepted' ? acceptedBets :
activeTab === 'active' ? activeBets :
historyBets
return (
<Layout>
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-gray-900">My Bets</h1>
<p className="text-gray-600 mt-2">View and manage your bets</p>
</div>
<div className="flex gap-2 border-b">
{[
{ key: 'active' as const, label: 'Active' },
{ key: 'created' as const, label: 'Created' },
{ key: 'accepted' as const, label: 'Accepted' },
{ key: 'history' as const, label: 'History' },
].map(({ key, label }) => (
<button
key={key}
onClick={() => setActiveTab(key)}
className={`px-6 py-3 font-medium transition-colors ${
activeTab === key
? 'text-primary border-b-2 border-primary'
: 'text-gray-600 hover:text-gray-900'
}`}
>
{label}
</button>
))}
</div>
{currentBets ? (
<BetList bets={currentBets} />
) : (
<Loading />
)}
</div>
</Layout>
)
}

View File

@ -0,0 +1,29 @@
import { Link } from 'react-router-dom'
import { RegisterForm } from '@/components/auth/RegisterForm'
import { Card } from '@/components/common/Card'
export const Register = () => {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4 py-8">
<div className="max-w-md w-full">
<div className="text-center mb-8">
<h1 className="text-4xl font-bold text-primary mb-2">H2H</h1>
<h2 className="text-2xl font-semibold text-gray-900">Create your account</h2>
</div>
<Card>
<RegisterForm />
<div className="mt-6 text-center">
<p className="text-sm text-gray-600">
Already have an account?{' '}
<Link to="/login" className="text-primary hover:underline font-medium">
Login here
</Link>
</p>
</div>
</Card>
</div>
</div>
)
}

View File

@ -0,0 +1,36 @@
import { useState } from 'react'
import { Layout } from '@/components/layout/Layout'
import { WalletBalance } from '@/components/wallet/WalletBalance'
import { DepositModal } from '@/components/wallet/DepositModal'
import { TransactionHistory } from '@/components/wallet/TransactionHistory'
import { Button } from '@/components/common/Button'
import { Plus } from 'lucide-react'
export const Wallet = () => {
const [isDepositModalOpen, setIsDepositModalOpen] = useState(false)
return (
<Layout>
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900">Wallet</h1>
<p className="text-gray-600 mt-2">Manage your funds and view transaction history</p>
</div>
<Button onClick={() => setIsDepositModalOpen(true)}>
<Plus size={20} className="mr-2" />
Deposit Funds
</Button>
</div>
<WalletBalance />
<TransactionHistory />
</div>
<DepositModal
isOpen={isDepositModalOpen}
onClose={() => setIsDepositModalOpen(false)}
/>
</Layout>
)
}

View File

@ -0,0 +1,74 @@
import { create } from 'zustand'
import type { User } from '@/types'
import { authApi } from '@/api/auth'
interface AuthState {
user: User | null
token: string | null
isAuthenticated: boolean
isLoading: boolean
login: (email: string, password: string) => Promise<void>
register: (email: string, username: string, password: string, displayName?: string) => Promise<void>
logout: () => void
loadUser: () => Promise<void>
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
token: localStorage.getItem('access_token'),
isAuthenticated: !!localStorage.getItem('access_token'),
isLoading: false,
login: async (email, password) => {
set({ isLoading: true })
try {
const response = await authApi.login({ email, password })
localStorage.setItem('access_token', response.access_token)
localStorage.setItem('refresh_token', response.refresh_token)
const user = await authApi.getCurrentUser()
set({ user, token: response.access_token, isAuthenticated: true, isLoading: false })
} catch (error) {
set({ isLoading: false })
throw error
}
},
register: async (email, username, password, displayName) => {
set({ isLoading: true })
try {
const response = await authApi.register({ email, username, password, display_name: displayName })
localStorage.setItem('access_token', response.access_token)
localStorage.setItem('refresh_token', response.refresh_token)
const user = await authApi.getCurrentUser()
set({ user, token: response.access_token, isAuthenticated: true, isLoading: false })
} catch (error) {
set({ isLoading: false })
throw error
}
},
logout: () => {
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
set({ user: null, token: null, isAuthenticated: false })
},
loadUser: async () => {
const token = localStorage.getItem('access_token')
if (!token) {
set({ isAuthenticated: false })
return
}
try {
const user = await authApi.getCurrentUser()
set({ user, isAuthenticated: true })
} catch (error) {
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
set({ user: null, token: null, isAuthenticated: false })
}
},
}))

112
frontend/src/types/index.ts Normal file
View File

@ -0,0 +1,112 @@
export interface User {
id: number
email: string
username: string
display_name: string | null
avatar_url: string | null
bio: string | null
total_bets: number
wins: number
losses: number
win_rate: number
status: 'active' | 'suspended' | 'pending_verification'
created_at: string
}
export interface UserSummary {
id: number
username: string
display_name: string | null
avatar_url: string | null
}
export interface Wallet {
id: number
user_id: number
balance: string
escrow: string
blockchain_escrow: string
currency: string
created_at: string
updated_at: string
}
export interface Transaction {
id: number
user_id: number
type: 'deposit' | 'withdrawal' | 'bet_placed' | 'bet_won' | 'bet_lost' | 'bet_cancelled' | 'escrow_lock' | 'escrow_release'
amount: string
balance_after: string
reference_id: number | null
description: string
status: 'pending' | 'completed' | 'failed'
created_at: string
}
export type BetCategory = 'sports' | 'esports' | 'politics' | 'entertainment' | 'custom'
export type BetStatus = 'open' | 'matched' | 'in_progress' | 'pending_result' | 'completed' | 'cancelled' | 'disputed'
export type BetVisibility = 'public' | 'private' | 'friends_only'
export interface Bet {
id: number
title: string
description: string
category: BetCategory
event_name: string
event_date: string | null
creator_position: string
opponent_position: string
creator_odds: number
opponent_odds: number
stake_amount: string
currency: string
status: BetStatus
visibility: BetVisibility
blockchain_bet_id: number | null
blockchain_tx_hash: string | null
blockchain_status: string | null
creator: UserSummary
opponent: UserSummary | null
expires_at: string | null
created_at: string
updated_at: string
}
export interface BetDetail extends Bet {
winner_id: number | null
settled_at: string | null
settled_by: string | null
}
export interface CreateBetData {
title: string
description: string
category: BetCategory
event_name: string
event_date?: string
creator_position: string
opponent_position: string
stake_amount: number
creator_odds?: number
opponent_odds?: number
visibility?: BetVisibility
expires_at?: string
}
export interface LoginData {
email: string
password: string
}
export interface RegisterData {
email: string
username: string
password: string
display_name?: string
}
export interface TokenResponse {
access_token: string
refresh_token: string
token_type: string
}

View File

@ -0,0 +1,30 @@
export const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
export const WS_URL = import.meta.env.VITE_WS_URL || 'ws://localhost:8000'
export const BET_CATEGORIES = {
sports: 'Sports',
esports: 'Esports',
politics: 'Politics',
entertainment: 'Entertainment',
custom: 'Custom',
} as const
export const BET_STATUS_LABELS = {
open: 'Open',
matched: 'Matched',
in_progress: 'In Progress',
pending_result: 'Pending Result',
completed: 'Completed',
cancelled: 'Cancelled',
disputed: 'Disputed',
} as const
export const BET_STATUS_COLORS = {
open: 'bg-blue-100 text-blue-800',
matched: 'bg-purple-100 text-purple-800',
in_progress: 'bg-yellow-100 text-yellow-800',
pending_result: 'bg-orange-100 text-orange-800',
completed: 'bg-green-100 text-green-800',
cancelled: 'bg-gray-100 text-gray-800',
disputed: 'bg-red-100 text-red-800',
} as const

View File

@ -0,0 +1,25 @@
import { format, formatDistanceToNow } from 'date-fns'
export const formatCurrency = (amount: string | number): string => {
const num = typeof amount === 'string' ? parseFloat(amount) : amount
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(num)
}
export const formatDate = (date: string): string => {
return format(new Date(date), 'MMM d, yyyy')
}
export const formatDateTime = (date: string): string => {
return format(new Date(date), 'MMM d, yyyy h:mm a')
}
export const formatRelativeTime = (date: string): string => {
return formatDistanceToNow(new Date(date), { addSuffix: true })
}
export const formatPercentage = (value: number): string => {
return `${(value * 100).toFixed(1)}%`
}

View File

@ -0,0 +1,18 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
primary: '#3B82F6',
success: '#10B981',
warning: '#F59E0B',
error: '#EF4444',
},
},
},
plugins: [],
}

31
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Path aliases */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

16
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
host: true,
port: 5173,
},
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Some files were not shown because too many files have changed in this diff Show More