Init.
This commit is contained in:
60
.gitignore
vendored
Normal file
60
.gitignore
vendored
Normal 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/
|
||||
504
BLOCKCHAIN_IMPLEMENTATION.md
Normal file
504
BLOCKCHAIN_IMPLEMENTATION.md
Normal 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
216
CLAUDE.md
Normal 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
|
||||
982
H2H_MVP_Claude_Code_Prompt_SQLite.md
Normal file
982
H2H_MVP_Claude_Code_Prompt_SQLite.md
Normal 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
196
README.md
Normal 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
5
backend/.env.example
Normal 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
17
backend/Dockerfile
Normal 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
0
backend/app/__init__.py
Normal file
20
backend/app/blockchain/__init__.py
Normal file
20
backend/app/blockchain/__init__.py
Normal 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",
|
||||
]
|
||||
189
backend/app/blockchain/config.py
Normal file
189
backend/app/blockchain/config.py
Normal 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
|
||||
"""
|
||||
563
backend/app/blockchain/contracts/BetEscrow.pseudocode.md
Normal file
563
backend/app/blockchain/contracts/BetEscrow.pseudocode.md
Normal 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;
|
||||
}
|
||||
```
|
||||
617
backend/app/blockchain/contracts/BetOracle.pseudocode.md
Normal file
617
backend/app/blockchain/contracts/BetOracle.pseudocode.md
Normal 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
|
||||
455
backend/app/blockchain/contracts/README.md
Normal file
455
backend/app/blockchain/contracts/README.md
Normal 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.
|
||||
24
backend/app/blockchain/services/__init__.py
Normal file
24
backend/app/blockchain/services/__init__.py
Normal 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",
|
||||
]
|
||||
427
backend/app/blockchain/services/blockchain_indexer.py
Normal file
427
backend/app/blockchain/services/blockchain_indexer.py
Normal 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()
|
||||
466
backend/app/blockchain/services/blockchain_service.py
Normal file
466
backend/app/blockchain/services/blockchain_service.py
Normal 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
|
||||
481
backend/app/blockchain/services/oracle_aggregator.py
Normal file
481
backend/app/blockchain/services/oracle_aggregator.py
Normal 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
|
||||
"""
|
||||
471
backend/app/blockchain/services/oracle_node.py
Normal file
471
backend/app/blockchain/services/oracle_node.py
Normal 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
14
backend/app/config.py
Normal 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()
|
||||
0
backend/app/crud/__init__.py
Normal file
0
backend/app/crud/__init__.py
Normal file
79
backend/app/crud/bet.py
Normal file
79
backend/app/crud/bet.py
Normal 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
56
backend/app/crud/user.py
Normal 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()
|
||||
54
backend/app/crud/wallet.py
Normal file
54
backend/app/crud/wallet.py
Normal 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
38
backend/app/database.py
Normal 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
47
backend/app/main.py
Normal 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"}
|
||||
19
backend/app/models/__init__.py
Normal file
19
backend/app/models/__init__.py
Normal 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
103
backend/app/models/bet.py
Normal 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")
|
||||
42
backend/app/models/transaction.py
Normal file
42
backend/app/models/transaction.py
Normal 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")
|
||||
41
backend/app/models/user.py
Normal file
41
backend/app/models/user.py
Normal 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")
|
||||
22
backend/app/models/wallet.py
Normal file
22
backend/app/models/wallet.py
Normal 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")
|
||||
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
82
backend/app/routers/auth.py
Normal file
82
backend/app/routers/auth.py
Normal 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
173
backend/app/routers/bets.py
Normal 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
|
||||
62
backend/app/routers/users.py
Normal file
62
backend/app/routers/users.py
Normal 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,
|
||||
)
|
||||
50
backend/app/routers/wallet.py
Normal file
50
backend/app/routers/wallet.py
Normal 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
|
||||
43
backend/app/routers/websocket.py
Normal file
43
backend/app/routers/websocket.py
Normal 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)
|
||||
47
backend/app/schemas/__init__.py
Normal file
47
backend/app/schemas/__init__.py
Normal 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",
|
||||
]
|
||||
89
backend/app/schemas/bet.py
Normal file
89
backend/app/schemas/bet.py
Normal 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)
|
||||
67
backend/app/schemas/user.py
Normal file
67
backend/app/schemas/user.py
Normal 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
|
||||
38
backend/app/schemas/wallet.py
Normal file
38
backend/app/schemas/wallet.py
Normal 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)
|
||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
50
backend/app/services/auth_service.py
Normal file
50
backend/app/services/auth_service.py
Normal 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,
|
||||
)
|
||||
178
backend/app/services/bet_service.py
Normal file
178
backend/app/services/bet_service.py
Normal 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
|
||||
52
backend/app/services/wallet_service.py
Normal file
52
backend/app/services/wallet_service.py
Normal 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
|
||||
0
backend/app/utils/__init__.py
Normal file
0
backend/app/utils/__init__.py
Normal file
50
backend/app/utils/exceptions.py
Normal file
50
backend/app/utils/exceptions.py
Normal 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")
|
||||
38
backend/app/utils/security.py
Normal file
38
backend/app/utils/security.py
Normal 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
0
backend/data/.gitkeep
Normal file
13
backend/requirements.txt
Normal file
13
backend/requirements.txt
Normal 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
125
backend/seed_data.py
Normal 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
34
docker-compose.yml
Normal 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
2
frontend/.env.example
Normal file
@ -0,0 +1,2 @@
|
||||
VITE_API_URL=http://localhost:8000
|
||||
VITE_WS_URL=ws://localhost:8000
|
||||
12
frontend/Dockerfile
Normal file
12
frontend/Dockerfile
Normal 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
13
frontend/index.html
Normal 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
3003
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
frontend/package.json
Normal file
31
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
89
frontend/src/App.tsx
Normal file
89
frontend/src/App.tsx
Normal 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
24
frontend/src/api/auth.ts
Normal 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
53
frontend/src/api/bets.ts
Normal 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
|
||||
},
|
||||
}
|
||||
32
frontend/src/api/client.ts
Normal file
32
frontend/src/api/client.ts
Normal 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)
|
||||
}
|
||||
)
|
||||
26
frontend/src/api/wallet.ts
Normal file
26
frontend/src/api/wallet.ts
Normal 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
|
||||
},
|
||||
}
|
||||
138
frontend/src/blockchain/components/BlockchainBadge.tsx
Normal file
138
frontend/src/blockchain/components/BlockchainBadge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
289
frontend/src/blockchain/components/TransactionModal.tsx
Normal file
289
frontend/src/blockchain/components/TransactionModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
310
frontend/src/blockchain/hooks/useBlockchainBet.ts
Normal file
310
frontend/src/blockchain/hooks/useBlockchainBet.ts
Normal 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
|
||||
}
|
||||
}
|
||||
191
frontend/src/blockchain/hooks/useGasEstimate.ts
Normal file
191
frontend/src/blockchain/hooks/useGasEstimate.ts
Normal 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
|
||||
}
|
||||
323
frontend/src/blockchain/hooks/useWeb3Wallet.ts
Normal file
323
frontend/src/blockchain/hooks/useWeb3Wallet.ts
Normal 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]
|
||||
}
|
||||
70
frontend/src/components/auth/LoginForm.tsx
Normal file
70
frontend/src/components/auth/LoginForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
101
frontend/src/components/auth/RegisterForm.tsx
Normal file
101
frontend/src/components/auth/RegisterForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
66
frontend/src/components/bets/BetCard.tsx
Normal file
66
frontend/src/components/bets/BetCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
24
frontend/src/components/bets/BetList.tsx
Normal file
24
frontend/src/components/bets/BetList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
272
frontend/src/components/bets/CreateBetModal.tsx
Normal file
272
frontend/src/components/bets/CreateBetModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
39
frontend/src/components/common/Button.tsx
Normal file
39
frontend/src/components/common/Button.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
14
frontend/src/components/common/Card.tsx
Normal file
14
frontend/src/components/common/Card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
7
frontend/src/components/common/Loading.tsx
Normal file
7
frontend/src/components/common/Loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
37
frontend/src/components/common/Modal.tsx
Normal file
37
frontend/src/components/common/Modal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
72
frontend/src/components/layout/Header.tsx
Normal file
72
frontend/src/components/layout/Header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
17
frontend/src/components/layout/Layout.tsx
Normal file
17
frontend/src/components/layout/Layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
65
frontend/src/components/wallet/DepositModal.tsx
Normal file
65
frontend/src/components/wallet/DepositModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
54
frontend/src/components/wallet/TransactionHistory.tsx
Normal file
54
frontend/src/components/wallet/TransactionHistory.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
94
frontend/src/components/wallet/WalletBalance.tsx
Normal file
94
frontend/src/components/wallet/WalletBalance.tsx
Normal 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
17
frontend/src/index.css
Normal 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
10
frontend/src/main.tsx
Normal 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>,
|
||||
)
|
||||
238
frontend/src/pages/BetDetails.tsx
Normal file
238
frontend/src/pages/BetDetails.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
75
frontend/src/pages/BetMarketplace.tsx
Normal file
75
frontend/src/pages/BetMarketplace.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
112
frontend/src/pages/Dashboard.tsx
Normal file
112
frontend/src/pages/Dashboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
61
frontend/src/pages/Home.tsx
Normal file
61
frontend/src/pages/Home.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
29
frontend/src/pages/Login.tsx
Normal file
29
frontend/src/pages/Login.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
78
frontend/src/pages/MyBets.tsx
Normal file
78
frontend/src/pages/MyBets.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
29
frontend/src/pages/Register.tsx
Normal file
29
frontend/src/pages/Register.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
36
frontend/src/pages/Wallet.tsx
Normal file
36
frontend/src/pages/Wallet.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
74
frontend/src/store/index.ts
Normal file
74
frontend/src/store/index.ts
Normal 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
112
frontend/src/types/index.ts
Normal 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
|
||||
}
|
||||
30
frontend/src/utils/constants.ts
Normal file
30
frontend/src/utils/constants.ts
Normal 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
|
||||
25
frontend/src/utils/formatters.ts
Normal file
25
frontend/src/utils/formatters.ts
Normal 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)}%`
|
||||
}
|
||||
18
frontend/tailwind.config.js
Normal file
18
frontend/tailwind.config.js
Normal 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
31
frontend/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal 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
16
frontend/vite.config.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
BIN
screenshots/01-login-page.png
Normal file
BIN
screenshots/01-login-page.png
Normal file
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
Reference in New Issue
Block a user