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

5
backend/.env.example Normal file
View File

@ -0,0 +1,5 @@
DATABASE_URL=sqlite+aiosqlite:///./data/h2h.db
JWT_SECRET=your-secret-key-change-in-production-min-32-chars
JWT_ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
REFRESH_TOKEN_EXPIRE_DAYS=7

17
backend/Dockerfile Normal file
View File

@ -0,0 +1,17 @@
FROM python:3.11-slim
WORKDIR /app
# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Create data directory
RUN mkdir -p data
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

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

View File

@ -0,0 +1,20 @@
"""
Blockchain Integration Package
This package provides smart contract integration for the H2H betting platform,
implementing a hybrid architecture where escrow and settlement occur on-chain
while maintaining fast queries through database caching.
Main components:
- contracts/: Smart contract pseudocode and architecture
- services/: Web3 integration, event indexing, and oracle network
- config.py: Blockchain configuration
"""
from .config import get_blockchain_config, CHAIN_IDS, BLOCK_EXPLORERS
__all__ = [
"get_blockchain_config",
"CHAIN_IDS",
"BLOCK_EXPLORERS",
]

View File

@ -0,0 +1,189 @@
"""
Blockchain Configuration
Centralized configuration for blockchain integration including:
- RPC endpoints
- Contract addresses
- Oracle node addresses
- Network settings
In production, these would be loaded from environment variables.
"""
from typing import Dict, Any
import os
# Network Configuration
NETWORK = os.getenv("BLOCKCHAIN_NETWORK", "sepolia") # sepolia, polygon-mumbai, mainnet, polygon
# RPC Endpoints
RPC_URLS = {
"mainnet": os.getenv("ETHEREUM_MAINNET_RPC", "https://eth-mainnet.alchemyapi.io/v2/YOUR-API-KEY"),
"sepolia": os.getenv("ETHEREUM_SEPOLIA_RPC", "https://eth-sepolia.alchemyapi.io/v2/YOUR-API-KEY"),
"polygon": os.getenv("POLYGON_MAINNET_RPC", "https://polygon-mainnet.g.alchemy.com/v2/YOUR-API-KEY"),
"polygon-mumbai": os.getenv("POLYGON_MUMBAI_RPC", "https://polygon-mumbai.g.alchemy.com/v2/YOUR-API-KEY"),
}
# Contract Addresses (per network)
CONTRACT_ADDRESSES = {
"mainnet": {
"bet_escrow": os.getenv("MAINNET_BET_ESCROW_ADDRESS", "0x0000000000000000000000000000000000000000"),
"bet_oracle": os.getenv("MAINNET_BET_ORACLE_ADDRESS", "0x0000000000000000000000000000000000000000"),
},
"sepolia": {
"bet_escrow": os.getenv("SEPOLIA_BET_ESCROW_ADDRESS", "0x1234567890abcdef1234567890abcdef12345678"),
"bet_oracle": os.getenv("SEPOLIA_BET_ORACLE_ADDRESS", "0xfedcba0987654321fedcba0987654321fedcba09"),
},
"polygon": {
"bet_escrow": os.getenv("POLYGON_BET_ESCROW_ADDRESS", "0x0000000000000000000000000000000000000000"),
"bet_oracle": os.getenv("POLYGON_BET_ORACLE_ADDRESS", "0x0000000000000000000000000000000000000000"),
},
"polygon-mumbai": {
"bet_escrow": os.getenv("MUMBAI_BET_ESCROW_ADDRESS", "0xabcdef1234567890abcdef1234567890abcdef12"),
"bet_oracle": os.getenv("MUMBAI_BET_ORACLE_ADDRESS", "0x234567890abcdef1234567890abcdef123456789"),
},
}
# Backend Signer Configuration
# IMPORTANT: In production, use a secure key management system (AWS KMS, HashiCorp Vault, etc.)
# Never commit private keys to version control
BACKEND_PRIVATE_KEY = os.getenv("BLOCKCHAIN_BACKEND_PRIVATE_KEY", "")
# Oracle Node Configuration
ORACLE_NODES = {
"node1": {
"address": os.getenv("ORACLE_NODE1_ADDRESS", "0xNode1Address..."),
"endpoint": os.getenv("ORACLE_NODE1_ENDPOINT", "https://oracle1.h2h.com"),
},
"node2": {
"address": os.getenv("ORACLE_NODE2_ADDRESS", "0xNode2Address..."),
"endpoint": os.getenv("ORACLE_NODE2_ENDPOINT", "https://oracle2.h2h.com"),
},
"node3": {
"address": os.getenv("ORACLE_NODE3_ADDRESS", "0xNode3Address..."),
"endpoint": os.getenv("ORACLE_NODE3_ENDPOINT", "https://oracle3.h2h.com"),
},
"node4": {
"address": os.getenv("ORACLE_NODE4_ADDRESS", "0xNode4Address..."),
"endpoint": os.getenv("ORACLE_NODE4_ENDPOINT", "https://oracle4.h2h.com"),
},
"node5": {
"address": os.getenv("ORACLE_NODE5_ADDRESS", "0xNode5Address..."),
"endpoint": os.getenv("ORACLE_NODE5_ENDPOINT", "https://oracle5.h2h.com"),
},
}
# Oracle Consensus Configuration
ORACLE_CONSENSUS_THRESHOLD = int(os.getenv("ORACLE_CONSENSUS_THRESHOLD", "3")) # 3 of 5 nodes must agree
ORACLE_TIMEOUT_SECONDS = int(os.getenv("ORACLE_TIMEOUT_SECONDS", "3600")) # 1 hour
# Indexer Configuration
INDEXER_POLL_INTERVAL = int(os.getenv("INDEXER_POLL_INTERVAL", "10")) # seconds
INDEXER_START_BLOCK = int(os.getenv("INDEXER_START_BLOCK", "0")) # 0 = contract deployment block
# Gas Configuration
GAS_PRICE_MULTIPLIER = float(os.getenv("GAS_PRICE_MULTIPLIER", "1.2")) # 20% above current gas price
MAX_GAS_PRICE_GWEI = int(os.getenv("MAX_GAS_PRICE_GWEI", "500")) # Never pay more than 500 gwei
# Transaction Configuration
TRANSACTION_TIMEOUT = int(os.getenv("TRANSACTION_TIMEOUT", "300")) # 5 minutes
CONFIRMATION_BLOCKS = int(os.getenv("CONFIRMATION_BLOCKS", "2")) # Wait for 2 block confirmations
# API Endpoints for Oracle Data Sources
API_ENDPOINTS = {
"espn": os.getenv("ESPN_API_KEY", "https://api.espn.com/v1"),
"odds_api": os.getenv("ODDS_API_KEY", "https://api.the-odds-api.com/v4"),
"oscars": os.getenv("OSCARS_API_KEY", "https://api.oscars.com"),
}
def get_blockchain_config(network: str = None) -> Dict[str, Any]:
"""
Get blockchain configuration for specified network.
Args:
network: Network name (mainnet, sepolia, polygon, polygon-mumbai)
If None, uses NETWORK from environment
Returns:
Dict with all blockchain configuration
"""
if network is None:
network = NETWORK
if network not in RPC_URLS:
raise ValueError(f"Unknown network: {network}")
return {
"network": network,
"rpc_url": RPC_URLS[network],
"bet_escrow_address": CONTRACT_ADDRESSES[network]["bet_escrow"],
"bet_oracle_address": CONTRACT_ADDRESSES[network]["bet_oracle"],
"backend_private_key": BACKEND_PRIVATE_KEY,
"oracle_nodes": ORACLE_NODES,
"oracle_consensus_threshold": ORACLE_CONSENSUS_THRESHOLD,
"oracle_timeout": ORACLE_TIMEOUT_SECONDS,
"indexer_poll_interval": INDEXER_POLL_INTERVAL,
"indexer_start_block": INDEXER_START_BLOCK,
"gas_price_multiplier": GAS_PRICE_MULTIPLIER,
"max_gas_price_gwei": MAX_GAS_PRICE_GWEI,
"transaction_timeout": TRANSACTION_TIMEOUT,
"confirmation_blocks": CONFIRMATION_BLOCKS,
"api_endpoints": API_ENDPOINTS,
}
# Network Chain IDs (for frontend)
CHAIN_IDS = {
"mainnet": 1,
"sepolia": 11155111,
"polygon": 137,
"polygon-mumbai": 80001,
}
# Block Explorer URLs (for frontend links)
BLOCK_EXPLORERS = {
"mainnet": "https://etherscan.io",
"sepolia": "https://sepolia.etherscan.io",
"polygon": "https://polygonscan.com",
"polygon-mumbai": "https://mumbai.polygonscan.com",
}
# Example .env file content:
"""
# Blockchain Configuration
BLOCKCHAIN_NETWORK=sepolia
# RPC Endpoints (get API keys from Alchemy, Infura, or QuickNode)
ETHEREUM_MAINNET_RPC=https://eth-mainnet.alchemyapi.io/v2/YOUR-API-KEY
ETHEREUM_SEPOLIA_RPC=https://eth-sepolia.alchemyapi.io/v2/YOUR-API-KEY
POLYGON_MAINNET_RPC=https://polygon-mainnet.g.alchemy.com/v2/YOUR-API-KEY
POLYGON_MUMBAI_RPC=https://polygon-mumbai.g.alchemy.com/v2/YOUR-API-KEY
# Contract Addresses (update after deployment)
SEPOLIA_BET_ESCROW_ADDRESS=0x1234567890abcdef1234567890abcdef12345678
SEPOLIA_BET_ORACLE_ADDRESS=0xfedcba0987654321fedcba0987654321fedcba09
# Backend Signer (NEVER commit to git!)
BLOCKCHAIN_BACKEND_PRIVATE_KEY=0xYOUR_PRIVATE_KEY_HERE
# Oracle Nodes
ORACLE_NODE1_ADDRESS=0xNode1Address...
ORACLE_NODE1_ENDPOINT=https://oracle1.h2h.com
ORACLE_CONSENSUS_THRESHOLD=3
# Indexer
INDEXER_START_BLOCK=12345678
INDEXER_POLL_INTERVAL=10
# Gas
GAS_PRICE_MULTIPLIER=1.2
MAX_GAS_PRICE_GWEI=500
# External APIs
ESPN_API_KEY=YOUR_ESPN_API_KEY
ODDS_API_KEY=YOUR_ODDS_API_KEY
"""

View File

@ -0,0 +1,563 @@
# BetEscrow Smart Contract (Pseudocode)
## Overview
The BetEscrow contract manages the entire lifecycle of peer-to-peer bets on the blockchain. It handles bet creation, escrow locking, settlement, and fund distribution in a trustless manner.
## State Variables
```solidity
// Contract state
mapping(uint256 => Bet) public bets;
mapping(address => uint256[]) public userBetIds;
uint256 public nextBetId;
address public oracleContract;
address public owner;
// Escrow tracking
mapping(uint256 => uint256) public escrowBalance; // betId => total locked amount
```
## Data Structures
```solidity
enum BetStatus {
OPEN, // Created, waiting for opponent
MATCHED, // Opponent accepted, funds locked
PENDING_ORACLE, // Waiting for oracle settlement
COMPLETED, // Settled and paid out
CANCELLED, // Cancelled before matching
DISPUTED // Settlement disputed
}
struct Bet {
uint256 betId;
address creator;
address opponent;
uint256 stakeAmount; // Amount each party stakes (in wei)
BetStatus status;
uint256 creatorOdds; // Multiplier for creator (scaled by 100, e.g., 150 = 1.5x)
uint256 opponentOdds; // Multiplier for opponent
uint256 createdAt; // Block timestamp
uint256 eventTimestamp; // When the real-world event occurs
bytes32 eventId; // External event identifier for oracle
address winner; // Winner address (null until settled)
uint256 settledAt; // Settlement timestamp
}
```
## Events
```solidity
event BetCreated(
uint256 indexed betId,
address indexed creator,
uint256 stakeAmount,
bytes32 eventId,
uint256 eventTimestamp
);
event BetMatched(
uint256 indexed betId,
address indexed opponent,
uint256 totalEscrow
);
event BetSettled(
uint256 indexed betId,
address indexed winner,
uint256 payoutAmount
);
event BetCancelled(
uint256 indexed betId,
address indexed creator
);
event BetDisputed(
uint256 indexed betId,
address indexed disputedBy,
uint256 timestamp
);
event EscrowLocked(
uint256 indexed betId,
address indexed user,
uint256 amount
);
event EscrowReleased(
uint256 indexed betId,
address indexed user,
uint256 amount
);
```
## Modifiers
```solidity
modifier onlyOracle() {
require(msg.sender == oracleContract, "Only oracle can call this");
_;
}
modifier onlyOwner() {
require(msg.sender == owner, "Only owner can call this");
_;
}
modifier betExists(uint256 betId) {
require(betId < nextBetId, "Bet does not exist");
_;
}
modifier onlyParticipant(uint256 betId) {
Bet storage bet = bets[betId];
require(
msg.sender == bet.creator || msg.sender == bet.opponent,
"Not a participant"
);
_;
}
```
## Core Functions
### 1. Create Bet
```solidity
function createBet(
uint256 stakeAmount,
uint256 creatorOdds,
uint256 opponentOdds,
uint256 eventTimestamp,
bytes32 eventId
) external returns (uint256) {
// Validation
require(stakeAmount > 0, "Stake must be positive");
require(stakeAmount <= 10000 ether, "Stake exceeds maximum");
require(creatorOdds > 0, "Creator odds must be positive");
require(opponentOdds > 0, "Opponent odds must be positive");
require(eventTimestamp > block.timestamp, "Event must be in future");
// Generate bet ID
uint256 betId = nextBetId++;
// Create bet (NO funds locked yet)
bets[betId] = Bet({
betId: betId,
creator: msg.sender,
opponent: address(0),
stakeAmount: stakeAmount,
status: BetStatus.OPEN,
creatorOdds: creatorOdds,
opponentOdds: opponentOdds,
createdAt: block.timestamp,
eventTimestamp: eventTimestamp,
eventId: eventId,
winner: address(0),
settledAt: 0
});
// Track user's bets
userBetIds[msg.sender].push(betId);
// Emit event
emit BetCreated(betId, msg.sender, stakeAmount, eventId, eventTimestamp);
return betId;
}
```
### 2. Accept Bet (Lock Escrow for Both Parties)
```solidity
function acceptBet(uint256 betId)
external
payable
betExists(betId)
{
Bet storage bet = bets[betId];
// Validation
require(bet.status == BetStatus.OPEN, "Bet not open");
require(msg.sender != bet.creator, "Cannot accept own bet");
require(msg.value == bet.stakeAmount, "Incorrect stake amount");
// CRITICAL: Lock creator's funds from their account
// NOTE: In production, creator would approve this contract to spend their funds
// For this pseudocode, assume creator has pre-approved the transfer
require(
transferFrom(bet.creator, address(this), bet.stakeAmount),
"Creator funds transfer failed"
);
// Lock opponent's funds (already sent with msg.value)
// Both stakes now held in contract
// Update escrow balance
escrowBalance[betId] = bet.stakeAmount * 2;
// Update bet state
bet.opponent = msg.sender;
bet.status = BetStatus.MATCHED;
// Track opponent's bets
userBetIds[msg.sender].push(betId);
// Emit events
emit EscrowLocked(betId, bet.creator, bet.stakeAmount);
emit EscrowLocked(betId, msg.sender, bet.stakeAmount);
emit BetMatched(betId, msg.sender, escrowBalance[betId]);
}
```
### 3. Request Oracle Settlement
```solidity
function requestSettlement(uint256 betId)
external
betExists(betId)
onlyParticipant(betId)
{
Bet storage bet = bets[betId];
// Validation
require(
bet.status == BetStatus.MATCHED,
"Bet must be matched"
);
require(
block.timestamp >= bet.eventTimestamp,
"Event has not occurred yet"
);
// Update status
bet.status = BetStatus.PENDING_ORACLE;
// Call oracle contract to request settlement
IOracleContract(oracleContract).requestSettlement(
betId,
bet.eventId
);
}
```
### 4. Settle Bet (Called by Oracle)
```solidity
function settleBet(uint256 betId, address winnerId)
external
betExists(betId)
onlyOracle
{
Bet storage bet = bets[betId];
// Validation
require(
bet.status == BetStatus.MATCHED ||
bet.status == BetStatus.PENDING_ORACLE,
"Invalid bet status for settlement"
);
require(
winnerId == bet.creator || winnerId == bet.opponent,
"Winner must be a participant"
);
// Calculate payout
uint256 totalPayout = escrowBalance[betId]; // Both stakes
// Transfer funds to winner
require(
payable(winnerId).transfer(totalPayout),
"Payout transfer failed"
);
// Update bet state
bet.winner = winnerId;
bet.status = BetStatus.COMPLETED;
bet.settledAt = block.timestamp;
// Clear escrow
escrowBalance[betId] = 0;
// Emit events
emit EscrowReleased(betId, winnerId, totalPayout);
emit BetSettled(betId, winnerId, totalPayout);
}
```
### 5. Dispute Settlement
```solidity
function disputeBet(uint256 betId)
external
betExists(betId)
onlyParticipant(betId)
{
Bet storage bet = bets[betId];
// Validation
require(
bet.status == BetStatus.PENDING_ORACLE ||
bet.status == BetStatus.COMPLETED,
"Can only dispute pending or completed bets"
);
// If completed, must dispute within 48 hours
if (bet.status == BetStatus.COMPLETED) {
require(
block.timestamp <= bet.settledAt + 48 hours,
"Dispute window expired"
);
}
// Mark as disputed
bet.status = BetStatus.DISPUTED;
emit BetDisputed(betId, msg.sender, block.timestamp);
}
```
### 6. Cancel Bet (Before Matching)
```solidity
function cancelBet(uint256 betId)
external
betExists(betId)
{
Bet storage bet = bets[betId];
// Validation
require(msg.sender == bet.creator, "Only creator can cancel");
require(bet.status == BetStatus.OPEN, "Can only cancel open bets");
// Mark as cancelled (no funds to refund since none were locked)
bet.status = BetStatus.CANCELLED;
emit BetCancelled(betId, msg.sender);
}
```
### 7. Admin Settlement (For Disputed Bets)
```solidity
function adminSettleBet(uint256 betId, address winnerId)
external
betExists(betId)
onlyOwner
{
Bet storage bet = bets[betId];
// Validation
require(bet.status == BetStatus.DISPUTED, "Only for disputed bets");
require(
winnerId == bet.creator || winnerId == bet.opponent,
"Winner must be a participant"
);
// Calculate payout
uint256 totalPayout = escrowBalance[betId];
// Transfer funds to winner
require(
payable(winnerId).transfer(totalPayout),
"Payout transfer failed"
);
// Update bet state
bet.winner = winnerId;
bet.status = BetStatus.COMPLETED;
bet.settledAt = block.timestamp;
// Clear escrow
escrowBalance[betId] = 0;
// Emit events
emit EscrowReleased(betId, winnerId, totalPayout);
emit BetSettled(betId, winnerId, totalPayout);
}
```
### 8. Manual Settlement (Fallback if Oracle Fails)
```solidity
function manualSettleAfterTimeout(uint256 betId, address winnerId)
external
betExists(betId)
onlyParticipant(betId)
{
Bet storage bet = bets[betId];
// Validation
require(
bet.status == BetStatus.PENDING_ORACLE,
"Must be waiting for oracle"
);
require(
block.timestamp >= bet.eventTimestamp + 24 hours,
"Oracle timeout not reached"
);
require(
winnerId == bet.creator || winnerId == bet.opponent,
"Winner must be a participant"
);
// Settle manually (without oracle)
// Note: Other participant can dispute within 48 hours
uint256 totalPayout = escrowBalance[betId];
require(
payable(winnerId).transfer(totalPayout),
"Payout transfer failed"
);
bet.winner = winnerId;
bet.status = BetStatus.COMPLETED;
bet.settledAt = block.timestamp;
escrowBalance[betId] = 0;
emit EscrowReleased(betId, winnerId, totalPayout);
emit BetSettled(betId, winnerId, totalPayout);
}
```
## View Functions
```solidity
function getBet(uint256 betId)
external
view
betExists(betId)
returns (Bet memory)
{
return bets[betId];
}
function getUserBets(address user)
external
view
returns (uint256[] memory)
{
return userBetIds[user];
}
function getUserEscrow(address user)
external
view
returns (uint256 totalEscrow)
{
uint256[] memory betIds = userBetIds[user];
for (uint256 i = 0; i < betIds.length; i++) {
Bet memory bet = bets[betIds[i]];
// Only count matched bets where user is a participant
if (bet.status == BetStatus.MATCHED || bet.status == BetStatus.PENDING_ORACLE) {
if (bet.creator == user || bet.opponent == user) {
totalEscrow += bet.stakeAmount;
}
}
}
return totalEscrow;
}
function getBetsByStatus(BetStatus status)
external
view
returns (uint256[] memory)
{
// Count matching bets
uint256 count = 0;
for (uint256 i = 0; i < nextBetId; i++) {
if (bets[i].status == status) {
count++;
}
}
// Populate array
uint256[] memory matchingBets = new uint256[](count);
uint256 index = 0;
for (uint256 i = 0; i < nextBetId; i++) {
if (bets[i].status == status) {
matchingBets[index] = i;
index++;
}
}
return matchingBets;
}
```
## State Machine Diagram
```
createBet()
[OPEN]
┌──────┴──────┐
│ │
cancelBet() acceptBet()
│ │
↓ ↓
[CANCELLED] [MATCHED]
requestSettlement()
[PENDING_ORACLE]
┌────────┴────────┐
│ │
settleBet() disputeBet()
│ │
↓ ↓
[COMPLETED] [DISPUTED]
adminSettleBet()
[COMPLETED]
```
## Security Features
1. **Reentrancy Protection**: Use checks-effects-interactions pattern
2. **Atomic Escrow**: Both parties' funds locked in single transaction
3. **Role-Based Access**: Only oracle can settle, only owner can admin-settle
4. **Dispute Window**: 48-hour window to dispute settlement
5. **Timeout Fallback**: Manual settlement if oracle fails after 24 hours
6. **Event Logging**: All state changes emit events for transparency
## Gas Optimization Notes
- Use `storage` pointers instead of loading full structs
- Pack struct fields to minimize storage slots
- Batch operations where possible
- Consider using `uint128` for stake amounts if max is known
- Use events instead of storing historical data on-chain
## Integration with Oracle
The BetEscrow contract delegates settlement authority to the Oracle contract. The oracle:
1. Listens for `requestSettlement()` calls
2. Fetches external API data
3. Determines winner via multi-node consensus
4. Calls `settleBet()` with the winner address
## Deployment Configuration
```solidity
constructor(address _oracleContract) {
owner = msg.sender;
oracleContract = _oracleContract;
nextBetId = 0;
}
function setOracleContract(address _newOracle) external onlyOwner {
oracleContract = _newOracle;
}
```

View File

@ -0,0 +1,617 @@
# BetOracle Smart Contract (Pseudocode)
## Overview
The BetOracle contract acts as a bridge between the blockchain and external data sources. It implements a decentralized oracle network where multiple independent nodes fetch data from APIs, reach consensus, and automatically settle bets based on real-world event outcomes.
## State Variables
```solidity
// Contract references
address public betEscrowContract;
address public owner;
// Oracle network
address[] public trustedNodes;
mapping(address => bool) public isNodeTrusted;
uint256 public consensusThreshold; // e.g., 3 of 5 nodes must agree
// Oracle requests
mapping(uint256 => OracleRequest) public requests;
uint256 public nextRequestId;
// Node submissions
mapping(uint256 => NodeSubmission[]) public submissions; // requestId => submissions
mapping(uint256 => mapping(address => bool)) public hasSubmitted; // requestId => node => submitted
```
## Data Structures
```solidity
enum RequestStatus {
PENDING, // Request created, waiting for nodes
FULFILLED, // Consensus reached, bet settled
DISPUTED, // No consensus or disputed result
TIMED_OUT // Oracle failed to respond in time
}
struct OracleRequest {
uint256 requestId;
uint256 betId;
bytes32 eventId; // External event identifier
string apiEndpoint; // URL to fetch result from
uint256 requestedAt; // Block timestamp
RequestStatus status;
address consensusWinner; // Agreed-upon winner
uint256 fulfilledAt; // Settlement timestamp
}
struct NodeSubmission {
address nodeAddress;
address proposedWinner; // Who the node thinks won
bytes resultData; // Raw API response data
bytes signature; // Node's signature of the result
uint256 submittedAt; // Block timestamp
}
struct ApiAdapter {
string apiEndpoint;
string resultPath; // JSON path to extract winner (e.g., "data.winner")
mapping(string => address) resultMapping; // Map API result to bet participant
}
```
## Events
```solidity
event OracleRequested(
uint256 indexed requestId,
uint256 indexed betId,
bytes32 eventId,
string apiEndpoint
);
event NodeSubmissionReceived(
uint256 indexed requestId,
address indexed node,
address proposedWinner
);
event ConsensusReached(
uint256 indexed requestId,
address indexed winner,
uint256 nodeCount
);
event OracleFulfilled(
uint256 indexed requestId,
uint256 indexed betId,
address winner
);
event OracleDisputed(
uint256 indexed requestId,
string reason
);
event TrustedNodeAdded(address indexed node);
event TrustedNodeRemoved(address indexed node);
```
## Modifiers
```solidity
modifier onlyBetEscrow() {
require(msg.sender == betEscrowContract, "Only BetEscrow can call");
_;
}
modifier onlyTrustedNode() {
require(isNodeTrusted[msg.sender], "Not a trusted oracle node");
_;
}
modifier onlyOwner() {
require(msg.sender == owner, "Only owner can call");
_;
}
modifier requestExists(uint256 requestId) {
require(requestId < nextRequestId, "Request does not exist");
_;
}
```
## Core Functions
### 1. Request Settlement (Called by BetEscrow)
```solidity
function requestSettlement(
uint256 betId,
bytes32 eventId
) external onlyBetEscrow returns (uint256) {
// Fetch bet details from BetEscrow contract
IBetEscrow.Bet memory bet = IBetEscrow(betEscrowContract).getBet(betId);
// Determine API endpoint based on event type
string memory apiEndpoint = getApiEndpointForEvent(eventId);
// Create oracle request
uint256 requestId = nextRequestId++;
requests[requestId] = OracleRequest({
requestId: requestId,
betId: betId,
eventId: eventId,
apiEndpoint: apiEndpoint,
requestedAt: block.timestamp,
status: RequestStatus.PENDING,
consensusWinner: address(0),
fulfilledAt: 0
});
// Emit event for off-chain oracle nodes to listen
emit OracleRequested(requestId, betId, eventId, apiEndpoint);
return requestId;
}
```
### 2. Submit Oracle Response (Called by Trusted Nodes)
```solidity
function submitOracleResponse(
uint256 requestId,
address proposedWinner,
bytes calldata resultData,
bytes calldata signature
)
external
onlyTrustedNode
requestExists(requestId)
{
OracleRequest storage request = requests[requestId];
// Validation
require(request.status == RequestStatus.PENDING, "Request not pending");
require(!hasSubmitted[requestId][msg.sender], "Already submitted");
// Verify signature
require(
verifyNodeSignature(requestId, proposedWinner, resultData, signature, msg.sender),
"Invalid signature"
);
// Store submission
submissions[requestId].push(NodeSubmission({
nodeAddress: msg.sender,
proposedWinner: proposedWinner,
resultData: resultData,
signature: signature,
submittedAt: block.timestamp
}));
hasSubmitted[requestId][msg.sender] = true;
emit NodeSubmissionReceived(requestId, msg.sender, proposedWinner);
// Check if consensus threshold reached
if (submissions[requestId].length >= consensusThreshold) {
_checkConsensusAndSettle(requestId);
}
}
```
### 3. Check Consensus and Settle
```solidity
function _checkConsensusAndSettle(uint256 requestId) internal {
OracleRequest storage request = requests[requestId];
NodeSubmission[] storage subs = submissions[requestId];
// Count votes for each proposed winner
mapping(address => uint256) memory voteCounts;
address[] memory candidates = new address[](subs.length);
uint256 candidateCount = 0;
for (uint256 i = 0; i < subs.length; i++) {
address candidate = subs[i].proposedWinner;
// Add to candidates if not already present
bool exists = false;
for (uint256 j = 0; j < candidateCount; j++) {
if (candidates[j] == candidate) {
exists = true;
break;
}
}
if (!exists) {
candidates[candidateCount] = candidate;
candidateCount++;
}
voteCounts[candidate]++;
}
// Find winner with most votes
address consensusWinner = address(0);
uint256 maxVotes = 0;
for (uint256 i = 0; i < candidateCount; i++) {
if (voteCounts[candidates[i]] > maxVotes) {
maxVotes = voteCounts[candidates[i]];
consensusWinner = candidates[i];
}
}
// Verify consensus threshold
if (maxVotes >= consensusThreshold) {
// Consensus reached - settle the bet
request.consensusWinner = consensusWinner;
request.status = RequestStatus.FULFILLED;
request.fulfilledAt = block.timestamp;
// Call BetEscrow to settle
IBetEscrow(betEscrowContract).settleBet(request.betId, consensusWinner);
emit ConsensusReached(requestId, consensusWinner, maxVotes);
emit OracleFulfilled(requestId, request.betId, consensusWinner);
} else {
// No consensus - mark as disputed
request.status = RequestStatus.DISPUTED;
emit OracleDisputed(requestId, "No consensus reached");
}
}
```
### 4. Verify Node Signature
```solidity
function verifyNodeSignature(
uint256 requestId,
address proposedWinner,
bytes memory resultData,
bytes memory signature,
address nodeAddress
) internal pure returns (bool) {
// Reconstruct the message hash
bytes32 messageHash = keccak256(
abi.encodePacked(requestId, proposedWinner, resultData)
);
// Add Ethereum signed message prefix
bytes32 ethSignedMessageHash = keccak256(
abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash)
);
// Recover signer from signature
address recoveredSigner = recoverSigner(ethSignedMessageHash, signature);
// Verify signer matches node address
return recoveredSigner == nodeAddress;
}
function recoverSigner(bytes32 ethSignedMessageHash, bytes memory signature)
internal
pure
returns (address)
{
(bytes32 r, bytes32 s, uint8 v) = splitSignature(signature);
return ecrecover(ethSignedMessageHash, v, r, s);
}
function splitSignature(bytes memory sig)
internal
pure
returns (bytes32 r, bytes32 s, uint8 v)
{
require(sig.length == 65, "Invalid signature length");
assembly {
r := mload(add(sig, 32))
s := mload(add(sig, 64))
v := byte(0, mload(add(sig, 96)))
}
}
```
### 5. Manual Dispute Resolution
```solidity
function resolveDispute(uint256 requestId, address winner)
external
onlyOwner
requestExists(requestId)
{
OracleRequest storage request = requests[requestId];
require(
request.status == RequestStatus.DISPUTED,
"Only for disputed requests"
);
// Admin manually resolves dispute
request.consensusWinner = winner;
request.status = RequestStatus.FULFILLED;
request.fulfilledAt = block.timestamp;
// Settle the bet via BetEscrow admin function
IBetEscrow(betEscrowContract).adminSettleBet(request.betId, winner);
emit OracleFulfilled(requestId, request.betId, winner);
}
```
### 6. Handle Timeout
```solidity
function markAsTimedOut(uint256 requestId)
external
requestExists(requestId)
{
OracleRequest storage request = requests[requestId];
require(request.status == RequestStatus.PENDING, "Not pending");
require(
block.timestamp >= request.requestedAt + 24 hours,
"Timeout period not reached"
);
request.status = RequestStatus.TIMED_OUT;
emit OracleDisputed(requestId, "Oracle timed out");
// Note: BetEscrow contract allows manual settlement after timeout
}
```
## Oracle Network Management
### Add Trusted Node
```solidity
function addTrustedNode(address node) external onlyOwner {
require(!isNodeTrusted[node], "Node already trusted");
trustedNodes.push(node);
isNodeTrusted[node] = true;
emit TrustedNodeAdded(node);
}
```
### Remove Trusted Node
```solidity
function removeTrustedNode(address node) external onlyOwner {
require(isNodeTrusted[node], "Node not trusted");
isNodeTrusted[node] = false;
// Remove from array
for (uint256 i = 0; i < trustedNodes.length; i++) {
if (trustedNodes[i] == node) {
trustedNodes[i] = trustedNodes[trustedNodes.length - 1];
trustedNodes.pop();
break;
}
}
emit TrustedNodeRemoved(node);
}
```
### Update Consensus Threshold
```solidity
function setConsensusThreshold(uint256 newThreshold) external onlyOwner {
require(newThreshold > 0, "Threshold must be positive");
require(newThreshold <= trustedNodes.length, "Threshold too high");
consensusThreshold = newThreshold;
}
```
## API Adapter Functions
### Get API Endpoint for Event
```solidity
mapping(bytes32 => ApiAdapter) public apiAdapters;
function getApiEndpointForEvent(bytes32 eventId)
internal
view
returns (string memory)
{
ApiAdapter storage adapter = apiAdapters[eventId];
require(bytes(adapter.apiEndpoint).length > 0, "No adapter for event");
return adapter.apiEndpoint;
}
```
### Register API Adapter
```solidity
function registerApiAdapter(
bytes32 eventId,
string memory apiEndpoint,
string memory resultPath
) external onlyOwner {
apiAdapters[eventId] = ApiAdapter({
apiEndpoint: apiEndpoint,
resultPath: resultPath
});
}
```
## View Functions
```solidity
function getRequest(uint256 requestId)
external
view
requestExists(requestId)
returns (OracleRequest memory)
{
return requests[requestId];
}
function getSubmissions(uint256 requestId)
external
view
requestExists(requestId)
returns (NodeSubmission[] memory)
{
return submissions[requestId];
}
function getTrustedNodes() external view returns (address[] memory) {
return trustedNodes;
}
function getVoteCounts(uint256 requestId)
external
view
requestExists(requestId)
returns (address[] memory candidates, uint256[] memory votes)
{
NodeSubmission[] storage subs = submissions[requestId];
// Count unique candidates
address[] memory tempCandidates = new address[](subs.length);
uint256[] memory tempVotes = new uint256[](subs.length);
uint256 candidateCount = 0;
for (uint256 i = 0; i < subs.length; i++) {
address candidate = subs[i].proposedWinner;
// Find or create candidate
bool found = false;
for (uint256 j = 0; j < candidateCount; j++) {
if (tempCandidates[j] == candidate) {
tempVotes[j]++;
found = true;
break;
}
}
if (!found) {
tempCandidates[candidateCount] = candidate;
tempVotes[candidateCount] = 1;
candidateCount++;
}
}
// Trim arrays to actual size
candidates = new address[](candidateCount);
votes = new uint256[](candidateCount);
for (uint256 i = 0; i < candidateCount; i++) {
candidates[i] = tempCandidates[i];
votes[i] = tempVotes[i];
}
return (candidates, votes);
}
```
## Oracle Flow Diagram
```
BetEscrow.requestSettlement()
OracleRequested event emitted
┌──────────┴──────────┐
│ │
Node 1 Node 2 Node 3 ... Node N
│ │ │
Fetch API Fetch API Fetch API
│ │ │
Sign Result Sign Result Sign Result
│ │ │
└──────────┬──────────┘ │
↓ │
submitOracleResponse() ←────────────────────┘
Check submissions count
┌──────────┴──────────┐
│ │
Count < threshold Count >= threshold
│ │
Wait for more ↓
submissions _checkConsensusAndSettle()
Count votes per winner
┌─────────┴─────────┐
│ │
Consensus No consensus
│ │
↓ ↓
BetEscrow.settleBet() DISPUTED status
Funds distributed
```
## Security Considerations
1. **Sybil Resistance**: Only trusted nodes can submit results
2. **Signature Verification**: All node submissions must be cryptographically signed
3. **Consensus Mechanism**: Require majority agreement (e.g., 3 of 5 nodes)
4. **Replay Protection**: Track submissions per request to prevent double-voting
5. **Timeout Handling**: Allow manual settlement if oracle network fails
6. **Admin Override**: Owner can resolve disputes for edge cases
## Example API Adapters
### Sports (ESPN API)
```solidity
Event ID: keccak256("nfl-super-bowl-2024")
API Endpoint: "https://api.espn.com/v1/sports/football/nfl/scoreboard?event=12345"
Result Path: "events[0].competitions[0].winner.team.name"
Result Mapping: {
"San Francisco 49ers": creatorAddress,
"Kansas City Chiefs": opponentAddress
}
```
### Entertainment (Awards API)
```solidity
Event ID: keccak256("oscars-2024-best-picture")
API Endpoint: "https://api.oscars.com/winners/2024"
Result Path: "categories.best_picture.winner"
Result Mapping: {
"Oppenheimer": creatorAddress,
"Other": opponentAddress
}
```
## Deployment Configuration
```solidity
constructor(address _betEscrowContract, uint256 _consensusThreshold) {
owner = msg.sender;
betEscrowContract = _betEscrowContract;
consensusThreshold = _consensusThreshold;
}
function setBetEscrowContract(address _newContract) external onlyOwner {
betEscrowContract = _newContract;
}
```
## Gas Optimization Notes
- Minimize storage writes in submission loop
- Use events for off-chain data indexing instead of storing all submissions
- Consider using Chainlink oracle infrastructure for production
- Batch consensus checks instead of checking on every submission

View File

@ -0,0 +1,455 @@
# H2H Blockchain Smart Contract Architecture
## Overview
This directory contains pseudocode specifications for the H2H betting platform's smart contract layer. The contracts implement a **hybrid blockchain architecture** where critical escrow and settlement operations occur on-chain for trustlessness, while user experience and auxiliary features remain off-chain for performance.
## Architecture Goals
1. **Trustless Escrow**: Funds are held by smart contracts, not a centralized authority
2. **Automatic Settlement**: Oracle network fetches real-world event results and settles bets
3. **Transparency**: All bet states and fund movements are publicly verifiable on-chain
4. **Decentralization**: Multiple independent oracle nodes prevent single point of failure
5. **Dispute Resolution**: Multi-layered fallback for edge cases and disagreements
## Contract Components
### 1. BetEscrow Contract
**Purpose**: Manages the complete lifecycle of bets including creation, matching, escrow, and settlement.
**Key Features**:
- Bet creation with configurable odds and stake amounts
- Atomic escrow locking for both parties upon bet acceptance
- Oracle-delegated settlement
- Manual settlement fallback if oracle fails
- Dispute mechanism with 48-hour window
- Admin override for disputed bets
**State Machine**:
```
OPEN → MATCHED → PENDING_ORACLE → COMPLETED
↓ ↑
CANCELLED DISPUTED
```
[See BetEscrow.pseudocode.md for full implementation](./BetEscrow.pseudocode.md)
### 2. BetOracle Contract
**Purpose**: Bridges blockchain and external data sources through a decentralized oracle network.
**Key Features**:
- Multi-node consensus mechanism (e.g., 3 of 5 nodes must agree)
- Cryptographic signature verification for all submissions
- Support for multiple API adapters (sports, entertainment, politics, etc.)
- Automatic settlement upon consensus
- Timeout handling with manual fallback
**Oracle Flow**:
```
Request → Nodes Fetch Data → Submit Results → Verify Consensus → Settle Bet
```
[See BetOracle.pseudocode.md for full implementation](./BetOracle.pseudocode.md)
## System Architecture Diagram
```
┌─────────────────────────────────────────────────────────────────────┐
│ Frontend (React + Web3) │
│ ┌──────────────┐ ┌──────────────┐ ┌────────────────────────┐ │
│ │ MetaMask │ │ Transaction │ │ Blockchain Status │ │
│ │ Connection │ │ Signing │ │ Badges & Gas Estimates │ │
│ └──────────────┘ └──────────────┘ └────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
↓ ↑
Web3 JSON-RPC
↓ ↑
┌─────────────────────────────────────────────────────────────────────┐
│ Blockchain Layer (Smart Contracts) │
│ │
│ ┌────────────────────────┐ ┌─────────────────────────┐ │
│ │ BetEscrow Contract │ ←────→ │ BetOracle Contract │ │
│ │ │ │ │ │
│ │ • createBet() │ │ • requestSettlement() │ │
│ │ • acceptBet() │ │ • submitResponse() │ │
│ │ • settleBet() │ │ • checkConsensus() │ │
│ │ • disputeBet() │ │ • verifySignatures() │ │
│ │ │ │ │ │
│ │ Escrow: ETH/tokens │ │ Trusted Nodes: 5 │ │
│ └────────────────────────┘ └─────────────────────────┘ │
│ ↓ Events ↑ API Calls │
└──────────────┼──────────────────────────────┼──────────────────────┘
│ │
│ (BetCreated, BetMatched, │ (Oracle Requests)
│ BetSettled events) │
↓ │
┌─────────────────────────────────────────────┼──────────────────────┐
│ Backend (FastAPI) │ │
│ │ │
│ ┌────────────────────┐ ┌───────────────┴────────┐ │
│ │ Event Indexer │ │ Oracle Aggregator │ │
│ │ │ │ │ │
│ │ • Poll blocks │ │ • Collect node votes │ │
│ │ • Index events │ │ • Verify consensus │ │
│ │ • Sync to DB │ │ • Submit to chain │ │
│ └────────────────────┘ └────────────────────────┘ │
│ ↓ ↑ │
│ PostgreSQL Oracle Nodes (3-5) │
│ ┌────────────────────┐ ┌────────────────────┐ │
│ │ Cached Bet Data │ │ • Fetch ESPN API │ │
│ │ (for fast queries)│ │ • Sign results │ │
│ └────────────────────┘ │ • Submit to chain │ │
│ └────────────────────┘ │
│ ↑ │
└──────────────────────────────────────┼──────────────────────────────┘
External APIs
┌─────────────────┴────────────────┐
│ │
┌──────┴──────┐ ┌─────────┴────────┐
│ ESPN API │ │ Odds API │
│ (Sports) │ │ (Politics, etc.)│
└─────────────┘ └──────────────────┘
```
## Complete Bet Lifecycle
### 1. Bet Creation
```
User (Alice)
Frontend: Fill out CreateBetModal
Web3: Sign transaction
BetEscrow.createBet(stake=100, odds=1.5, eventId="super-bowl-2024")
Emit BetCreated event
Backend Indexer: Sync to PostgreSQL
Marketplace: Bet appears as "OPEN"
```
**On-Chain State**:
- Bet ID: 42
- Status: OPEN
- Creator: Alice's address
- Opponent: null
- Escrow: 0 ETH (no funds locked yet)
**Off-Chain State (DB)**:
- Title: "Super Bowl LVIII Winner"
- Description: "49ers vs Chiefs..."
- blockchain_bet_id: 42
- blockchain_tx_hash: "0xabc123..."
### 2. Bet Acceptance
```
User (Bob)
Frontend: Click "Accept Bet" on BetDetails page
Display GasFeeEstimate component
User confirms
Web3: Sign transaction with stake amount (100 ETH)
BetEscrow.acceptBet(betId=42) payable
Contract transfers Alice's 100 ETH from her approved balance
Contract receives Bob's 100 ETH from msg.value
Escrow locked: 200 ETH total
Emit BetMatched event
Frontend: Show TransactionModal ("Confirming...")
Wait for block confirmation (10-30 seconds)
TransactionModal: "Success!" with Etherscan link
Backend Indexer: Update DB status to MATCHED
Frontend: Refresh bet details, show "Matched" status
```
**On-Chain State**:
- Bet ID: 42
- Status: MATCHED
- Creator: Alice's address
- Opponent: Bob's address
- Escrow: 200 ETH
**Off-Chain State (DB)**:
- status: matched
- opponent_id: Bob's user ID
- blockchain_status: "MATCHED"
### 3. Oracle Settlement (Automatic)
```
Event occurs in real world (Super Bowl game ends)
BetEscrow.requestSettlement(betId=42)
BetOracle.requestSettlement(betId=42, eventId="super-bowl-2024")
Emit OracleRequested event
┌─────────────────────────────────────┐
│ Oracle Nodes (listening for events) │
└─────────────────────────────────────┘
↓ ↓ ↓
Node 1 Node 2 Node 3
│ │ │
Fetch ESPN Fetch ESPN Fetch ESPN
API result API result API result
│ │ │
"49ers won" "49ers won" "49ers won"
│ │ │
Map to Alice Map to Alice Map to Alice
│ │ │
Sign result Sign result Sign result
↓ ↓ ↓
BetOracle.submitOracleResponse(requestId, winner=Alice, signature)
Check submissions count (3/3)
Check consensus (3 votes for Alice)
Consensus reached!
BetEscrow.settleBet(betId=42, winner=Alice)
Transfer 200 ETH to Alice
Emit BetSettled event
Backend Indexer: Update DB
Frontend: Show "Completed" status
Alice's wallet balance: +200 ETH
```
**On-Chain State**:
- Bet ID: 42
- Status: COMPLETED
- Winner: Alice's address
- Escrow: 0 ETH (paid out)
**Off-Chain State (DB)**:
- status: completed
- winner_id: Alice's user ID
- settled_at: timestamp
## Integration Points
### Frontend → Blockchain
**Files**: `frontend/src/blockchain/hooks/useBlockchainBet.ts`
```typescript
// Example: Accepting a bet
const { acceptBet } = useBlockchainBet()
await acceptBet(betId=42, stakeAmount=100)
MetaMask popup for user signature
Transaction submitted to blockchain
Wait for confirmation
Update UI with new bet status
```
### Backend → Blockchain
**Files**: `backend/app/blockchain/services/blockchain_service.py`
```python
# Example: Creating bet on-chain
blockchain_service.create_bet_on_chain(
stake_amount=100,
event_id="super-bowl-2024"
)
Build transaction with Web3.py
Sign with backend hot wallet
Submit to RPC endpoint
Return transaction hash
```
### Blockchain → Backend (Event Indexing)
**Files**: `backend/app/blockchain/services/blockchain_indexer.py`
```python
# Continuous background process
while True:
latest_block = get_latest_block()
events = get_events_in_block(latest_block)
for event in events:
if event.type == "BetCreated":
sync_bet_created_to_db(event)
elif event.type == "BetMatched":
sync_bet_matched_to_db(event)
elif event.type == "BetSettled":
sync_bet_settled_to_db(event)
sleep(10 seconds)
```
## Data Flow: On-Chain vs Off-Chain
| Data | Storage Location | Reason |
|------|------------------|--------|
| Bet escrow funds | ⛓️ On-Chain | Trustless, no centralized custody |
| Bet status (OPEN/MATCHED/COMPLETED) | ⛓️ On-Chain + 💾 DB Cache | Source of truth on-chain, cached for speed |
| Bet participants (creator/opponent) | ⛓️ On-Chain + 💾 DB | Enforced by smart contract |
| Settlement winner | ⛓️ On-Chain | Oracle consensus, immutable |
| Bet title/description | 💾 DB only | Not needed for contract logic |
| User email/password | 💾 DB only | Authentication separate from blockchain |
| Transaction history | ⛓️ On-Chain (events) + 💾 DB | Events logged on-chain, indexed to DB |
| Search/filters | 💾 DB only | Too expensive to query blockchain |
## Security Model
### On-Chain Security
1. **Escrow Protection**: Funds locked in smart contract, not controlled by any individual
2. **Atomic Operations**: Both parties' funds locked in single transaction (no partial states)
3. **Role-Based Access**: Only oracle can settle, only owner can admin-override
4. **Reentrancy Guards**: Prevent malicious contracts from draining escrow
5. **Signature Verification**: All oracle submissions cryptographically signed
### Oracle Security
1. **Multi-Node Consensus**: Require 3 of 5 nodes to agree (prevents single node manipulation)
2. **Signature Verification**: Each node signs results with private key
3. **Timeout Fallback**: If oracle fails, users can manually settle after 24 hours
4. **Dispute Window**: 48 hours to dispute automatic settlement
5. **Admin Override**: Final fallback for edge cases
### Threat Model
| Threat | Mitigation |
|--------|-----------|
| Malicious oracle node | Multi-node consensus (3/5 threshold) |
| All oracle nodes fail | 24-hour timeout → manual settlement allowed |
| Disputed result | 48-hour dispute window → admin resolution |
| Smart contract bug | Audited code, time-lock for upgrades |
| User loses private key | Non-custodial, user responsible (standard Web3) |
| Front-running | Commit-reveal scheme (future enhancement) |
## Gas Costs & Optimization
### Estimated Gas Usage (Ethereum Mainnet)
| Operation | Gas Limit | Cost @ 50 gwei | Cost @ 100 gwei |
|-----------|-----------|----------------|-----------------|
| Create Bet | 120,000 | 0.006 ETH ($12) | 0.012 ETH ($24) |
| Accept Bet | 180,000 | 0.009 ETH ($18) | 0.018 ETH ($36) |
| Settle Bet (Oracle) | 150,000 | 0.0075 ETH ($15) | 0.015 ETH ($30) |
| Submit Oracle Response | 80,000 | 0.004 ETH ($8) | 0.008 ETH ($16) |
### Layer 2 Optimization
**Recommendation**: Deploy on Polygon or Arbitrum for:
- **90-95% lower gas costs**: Accept bet costs ~$2 instead of $36
- **Faster confirmations**: 2 seconds vs 12-15 seconds
- **Same security**: Inherits Ethereum security via rollups
- **No code changes**: Same Solidity contracts work on L2
**Example L2 Costs (Polygon)**:
- Create Bet: ~$0.50
- Accept Bet: ~$1.00
- Settle Bet: ~$0.75
## Testing Strategy
### Unit Tests
- Test each contract function individually
- Verify state transitions (OPEN → MATCHED → COMPLETED)
- Test edge cases (insufficient funds, unauthorized access, etc.)
### Integration Tests
- Test BetEscrow ↔ BetOracle integration
- Test oracle consensus mechanism with simulated nodes
- Test timeout and fallback scenarios
### End-to-End Tests
- Deploy contracts to testnet (Sepolia/Mumbai)
- Create bet through frontend
- Accept bet with second account
- Trigger oracle settlement
- Verify funds distributed correctly
## Deployment Plan
### Phase 1: Testnet Deployment
1. Deploy contracts to Sepolia (Ethereum) or Mumbai (Polygon)
2. Verify contracts on Etherscan
3. Set up 3 oracle nodes
4. Configure API adapters for test events
5. Test full lifecycle with test ETH
### Phase 2: Security Audit
1. Engage smart contract auditor (CertiK, OpenZeppelin, Trail of Bits)
2. Fix any discovered vulnerabilities
3. Re-audit critical changes
### Phase 3: Mainnet Launch
1. Deploy to Ethereum mainnet or Polygon
2. Transfer ownership to multi-sig wallet
3. Launch with limited beta users
4. Monitor for 2 weeks before full launch
## Future Enhancements
1. **EIP-2771 Meta-Transactions**: Backend pays gas for user actions
2. **Batch Settlement**: Settle multiple bets in single transaction
3. **Flexible Odds**: Support decimal odds like 1.75x instead of whole numbers
4. **Partial Matching**: Allow bets to be partially filled by multiple opponents
5. **NFT Receipts**: Issue NFTs representing bet participation
6. **DAO Governance**: Community votes on oracle disputes
## Repository Structure
```
backend/app/blockchain/
├── contracts/
│ ├── BetEscrow.pseudocode.md # This file
│ ├── BetOracle.pseudocode.md # Oracle contract spec
│ └── README.md # Architecture overview (you are here)
├── services/
│ ├── blockchain_service.py # Web3 integration
│ ├── blockchain_indexer.py # Event listener
│ ├── oracle_node.py # Oracle node implementation
│ └── oracle_aggregator.py # Consensus aggregator
└── config.py # Contract addresses, RPC URLs
```
## References
- [Ethereum Smart Contracts](https://ethereum.org/en/developers/docs/smart-contracts/)
- [Chainlink Oracles](https://chain.link/education/blockchain-oracles)
- [OpenZeppelin Contracts](https://docs.openzeppelin.com/contracts/)
- [Solidity by Example](https://solidity-by-example.org/)
- [Web3.py Documentation](https://web3py.readthedocs.io/)
---
**Note**: This is pseudocode for architectural planning. For production deployment, these contracts would need to be written in actual Solidity, audited for security, and thoroughly tested on testnets before mainnet launch.

View File

@ -0,0 +1,24 @@
"""
Blockchain Services
Core services for blockchain integration:
- BlockchainService: Web3 provider and contract interactions
- BlockchainIndexer: Event listener and database sync
- OracleNode: Fetches external API data
- OracleAggregator: Achieves consensus among oracle nodes
"""
from .blockchain_service import BlockchainService, get_blockchain_service
from .blockchain_indexer import BlockchainIndexer, get_blockchain_indexer
from .oracle_node import OracleNode
from .oracle_aggregator import OracleAggregator, get_oracle_aggregator
__all__ = [
"BlockchainService",
"get_blockchain_service",
"BlockchainIndexer",
"get_blockchain_indexer",
"OracleNode",
"OracleAggregator",
"get_oracle_aggregator",
]

View File

@ -0,0 +1,427 @@
"""
Blockchain Event Indexer
This service continuously polls the blockchain for new events emitted by
BetEscrow and BetOracle contracts, then syncs them to the PostgreSQL database.
This allows the hybrid architecture to:
1. Use blockchain as source of truth for escrow and settlement
2. Maintain fast queries through cached database records
3. Provide real-time updates to users via WebSocket
NOTE: This is pseudocode/skeleton showing the architecture.
"""
import asyncio
from typing import Dict, Any, List
from datetime import datetime
class BlockchainIndexer:
"""
Indexes blockchain events and syncs to database.
Continuously polls for new blocks, extracts events, and updates
the PostgreSQL database to match on-chain state.
"""
def __init__(self, blockchain_service, database_session):
"""
Initialize indexer.
Args:
blockchain_service: Instance of BlockchainService
database_session: SQLAlchemy async session
"""
self.blockchain_service = blockchain_service
self.db = database_session
self.is_running = False
self.poll_interval = 10 # seconds
async def start(self):
"""
Start the indexer background worker.
This runs continuously, polling for new blocks every 10 seconds.
"""
self.is_running = True
print("Blockchain indexer started")
try:
while self.is_running:
await self._index_new_blocks()
await asyncio.sleep(self.poll_interval)
except Exception as e:
print(f"Indexer error: {e}")
self.is_running = False
async def stop(self):
"""Stop the indexer."""
self.is_running = False
print("Blockchain indexer stopped")
async def _index_new_blocks(self):
"""
Index all new blocks since last indexed block.
Flow:
1. Get last indexed block from database
2. Get current blockchain height
3. For each new block, extract and process events
4. Update last indexed block
"""
# Pseudocode:
#
# # Get last indexed block from database
# last_indexed_block = await self.db.execute(
# select(BlockchainSync).order_by(BlockchainSync.block_number.desc()).limit(1)
# )
# last_block = last_indexed_block.scalar_one_or_none()
#
# if last_block:
# start_block = last_block.block_number + 1
# else:
# # First time indexing - start from contract deployment block
# start_block = DEPLOYMENT_BLOCK_NUMBER
#
# # Get current blockchain height
# current_block = await self.blockchain_service.web3.eth.block_number
#
# if start_block > current_block:
# return # No new blocks
#
# print(f"Indexing blocks {start_block} to {current_block}")
#
# # Index each block
# for block_num in range(start_block, current_block + 1):
# await self._index_block(block_num)
#
# # Update last indexed block
# sync_record = BlockchainSync(
# block_number=current_block,
# indexed_at=datetime.utcnow()
# )
# self.db.add(sync_record)
# await self.db.commit()
# Placeholder for pseudocode
print("[Indexer] Checking for new blocks...")
await asyncio.sleep(0.1)
async def _index_block(self, block_number: int):
"""
Index all events in a specific block.
Args:
block_number: Block number to index
"""
# Pseudocode:
#
# # Get BetCreated events
# bet_created_events = await self.blockchain_service.bet_escrow_contract.events.BetCreated.getLogs(
# fromBlock=block_number,
# toBlock=block_number
# )
#
# for event in bet_created_events:
# await self._handle_bet_created(event)
#
# # Get BetMatched events
# bet_matched_events = await self.blockchain_service.bet_escrow_contract.events.BetMatched.getLogs(
# fromBlock=block_number,
# toBlock=block_number
# )
#
# for event in bet_matched_events:
# await self._handle_bet_matched(event)
#
# # Get BetSettled events
# bet_settled_events = await self.blockchain_service.bet_escrow_contract.events.BetSettled.getLogs(
# fromBlock=block_number,
# toBlock=block_number
# )
#
# for event in bet_settled_events:
# await self._handle_bet_settled(event)
#
# # Get BetDisputed events
# bet_disputed_events = await self.blockchain_service.bet_escrow_contract.events.BetDisputed.getLogs(
# fromBlock=block_number,
# toBlock=block_number
# )
#
# for event in bet_disputed_events:
# await self._handle_bet_disputed(event)
print(f"[Indexer] Indexing block {block_number}")
await asyncio.sleep(0.1)
async def _handle_bet_created(self, event: Dict[str, Any]):
"""
Handle BetCreated event.
Event structure:
{
'args': {
'betId': 42,
'creator': '0xAlice...',
'stakeAmount': 100000000000000000000, # 100 ETH in wei
'eventId': '0xsuper-bowl-2024',
'eventTimestamp': 1704067200
},
'transactionHash': '0xabc123...',
'blockNumber': 12345
}
Syncs to database by updating the bet record with blockchain fields.
"""
# Pseudocode:
#
# bet_id = event['args']['betId']
# creator_address = event['args']['creator']
# stake_amount = self.blockchain_service.web3.fromWei(event['args']['stakeAmount'], 'ether')
# tx_hash = event['transactionHash'].hex()
# block_number = event['blockNumber']
#
# # Find bet in database by creator address
# # (Bet was already created via backend API with local ID)
# user = await self.db.execute(
# select(User).where(User.wallet_address == creator_address)
# )
# user = user.scalar_one_or_none()
#
# if user:
# # Find the most recent bet created by this user without blockchain_bet_id
# bet = await self.db.execute(
# select(Bet)
# .where(Bet.creator_id == user.id, Bet.blockchain_bet_id.is_(None))
# .order_by(Bet.created_at.desc())
# .limit(1)
# )
# bet = bet.scalar_one_or_none()
#
# if bet:
# # Update with blockchain data
# bet.blockchain_bet_id = bet_id
# bet.blockchain_tx_hash = tx_hash
# bet.blockchain_status = 'OPEN'
# await self.db.commit()
#
# print(f"[Indexer] BetCreated: bet_id={bet_id}, tx={tx_hash}")
print(f"[Indexer] BetCreated event: {event}")
await asyncio.sleep(0.1)
async def _handle_bet_matched(self, event: Dict[str, Any]):
"""
Handle BetMatched event.
Event structure:
{
'args': {
'betId': 42,
'opponent': '0xBob...',
'totalEscrow': 200000000000000000000 # 200 ETH in wei
},
'transactionHash': '0xdef456...',
'blockNumber': 12346
}
Updates bet status to MATCHED and records opponent.
"""
# Pseudocode:
#
# bet_id = event['args']['betId']
# opponent_address = event['args']['opponent']
# total_escrow = self.blockchain_service.web3.fromWei(event['args']['totalEscrow'], 'ether')
# tx_hash = event['transactionHash'].hex()
#
# # Find bet by blockchain_bet_id
# bet = await self.db.execute(
# select(Bet).where(Bet.blockchain_bet_id == bet_id)
# )
# bet = bet.scalar_one_or_none()
#
# if bet:
# # Find opponent user by wallet address
# opponent = await self.db.execute(
# select(User).where(User.wallet_address == opponent_address)
# )
# opponent = opponent.scalar_one_or_none()
#
# if opponent:
# bet.opponent_id = opponent.id
# bet.status = BetStatus.MATCHED
# bet.blockchain_status = 'MATCHED'
# await self.db.commit()
#
# # Create transaction records for both parties
# await create_escrow_lock_transaction(bet.creator_id, bet.stake_amount, bet.id)
# await create_escrow_lock_transaction(opponent.id, bet.stake_amount, bet.id)
#
# # Send WebSocket notification
# await send_websocket_event("bet_matched", {"bet_id": bet.id})
#
# print(f"[Indexer] BetMatched: bet_id={bet_id}, opponent={opponent_address}")
print(f"[Indexer] BetMatched event: {event}")
await asyncio.sleep(0.1)
async def _handle_bet_settled(self, event: Dict[str, Any]):
"""
Handle BetSettled event.
Event structure:
{
'args': {
'betId': 42,
'winner': '0xAlice...',
'payoutAmount': 200000000000000000000 # 200 ETH in wei
},
'transactionHash': '0xghi789...',
'blockNumber': 12347
}
Updates bet to COMPLETED and records winner.
"""
# Pseudocode:
#
# bet_id = event['args']['betId']
# winner_address = event['args']['winner']
# payout_amount = self.blockchain_service.web3.fromWei(event['args']['payoutAmount'], 'ether')
# tx_hash = event['transactionHash'].hex()
#
# # Find bet
# bet = await self.db.execute(
# select(Bet).where(Bet.blockchain_bet_id == bet_id)
# )
# bet = bet.scalar_one_or_none()
#
# if bet:
# # Find winner user
# winner = await self.db.execute(
# select(User).where(User.wallet_address == winner_address)
# )
# winner = winner.scalar_one_or_none()
#
# if winner:
# bet.winner_id = winner.id
# bet.status = BetStatus.COMPLETED
# bet.blockchain_status = 'COMPLETED'
# bet.settled_at = datetime.utcnow()
# await self.db.commit()
#
# # Create transaction records
# loser_id = bet.opponent_id if winner.id == bet.creator_id else bet.creator_id
#
# await create_bet_won_transaction(winner.id, payout_amount, bet.id)
# await create_bet_lost_transaction(loser_id, bet.stake_amount, bet.id)
#
# # Update user stats
# await update_user_stats(winner.id, won=True)
# await update_user_stats(loser_id, won=False)
#
# # Send WebSocket notifications
# await send_websocket_event("bet_settled", {"bet_id": bet.id, "winner_id": winner.id})
#
# print(f"[Indexer] BetSettled: bet_id={bet_id}, winner={winner_address}")
print(f"[Indexer] BetSettled event: {event}")
await asyncio.sleep(0.1)
async def _handle_bet_disputed(self, event: Dict[str, Any]):
"""
Handle BetDisputed event.
Event structure:
{
'args': {
'betId': 42,
'disputedBy': '0xBob...',
'timestamp': 1704153600
},
'transactionHash': '0xjkl012...',
'blockNumber': 12348
}
Updates bet status to DISPUTED.
"""
# Pseudocode:
#
# bet_id = event['args']['betId']
# disputed_by_address = event['args']['disputedBy']
#
# bet = await self.db.execute(
# select(Bet).where(Bet.blockchain_bet_id == bet_id)
# )
# bet = bet.scalar_one_or_none()
#
# if bet:
# bet.status = BetStatus.DISPUTED
# bet.blockchain_status = 'DISPUTED'
# await self.db.commit()
#
# # Notify admins for manual review
# await send_admin_notification("Bet disputed", {"bet_id": bet.id})
#
# print(f"[Indexer] BetDisputed: bet_id={bet_id}, disputed_by={disputed_by_address}")
print(f"[Indexer] BetDisputed event: {event}")
await asyncio.sleep(0.1)
async def reindex_from_block(self, start_block: int):
"""
Reindex blockchain events from a specific block.
Useful for recovering from indexer downtime or bugs.
Args:
start_block: Block number to start reindexing from
"""
# Pseudocode:
#
# current_block = await self.blockchain_service.web3.eth.block_number
#
# print(f"Reindexing from block {start_block} to {current_block}")
#
# for block_num in range(start_block, current_block + 1):
# await self._index_block(block_num)
# if block_num % 100 == 0:
# print(f"Progress: {block_num}/{current_block}")
#
# print("Reindexing complete")
print(f"[Indexer] Reindexing from block {start_block}")
await asyncio.sleep(0.1)
# Singleton instance
_indexer: BlockchainIndexer | None = None
def get_blockchain_indexer(blockchain_service, database_session) -> BlockchainIndexer:
"""Get singleton indexer instance."""
global _indexer
if _indexer is None:
_indexer = BlockchainIndexer(blockchain_service, database_session)
return _indexer
async def start_indexer_background_task(blockchain_service, database_session):
"""
Start the indexer as a background task.
This would be called from main.py on application startup:
@app.on_event("startup")
async def startup_event():
blockchain_service = get_blockchain_service()
db_session = get_database_session()
indexer = get_blockchain_indexer(blockchain_service, db_session)
asyncio.create_task(indexer.start())
"""
indexer = get_blockchain_indexer(blockchain_service, database_session)
await indexer.start()

View File

@ -0,0 +1,466 @@
"""
Blockchain Service - Bridge between H2H Backend and Smart Contracts
This service provides a Web3 integration layer for interacting with BetEscrow
and BetOracle smart contracts. It handles transaction building, signing, and
submission to the blockchain.
NOTE: This is pseudocode/skeleton showing the architecture.
In production, you would use web3.py library.
"""
from decimal import Decimal
from typing import Dict, Any, Optional
import asyncio
class BlockchainService:
"""
Main service for blockchain interactions.
Responsibilities:
- Connect to blockchain RPC endpoint
- Load contract ABIs and addresses
- Build and sign transactions
- Estimate gas costs
- Submit transactions and wait for confirmation
"""
def __init__(
self,
rpc_url: str,
bet_escrow_address: str,
bet_oracle_address: str,
private_key: str
):
"""
Initialize blockchain service.
Args:
rpc_url: Blockchain RPC endpoint (e.g., Infura, Alchemy)
bet_escrow_address: Deployed BetEscrow contract address
bet_oracle_address: Deployed BetOracle contract address
private_key: Private key for backend signer account
"""
# Pseudocode: Initialize Web3 connection
# self.web3 = Web3(HTTPProvider(rpc_url))
# self.account = Account.from_key(private_key)
# Load contract ABIs and create contract instances
# self.bet_escrow_contract = self.web3.eth.contract(
# address=bet_escrow_address,
# abi=load_abi("BetEscrow")
# )
# self.bet_oracle_contract = self.web3.eth.contract(
# address=bet_oracle_address,
# abi=load_abi("BetOracle")
# )
self.rpc_url = rpc_url
self.bet_escrow_address = bet_escrow_address
self.bet_oracle_address = bet_oracle_address
self.signer_address = "DERIVED_FROM_PRIVATE_KEY"
async def create_bet_on_chain(
self,
stake_amount: Decimal,
creator_odds: float,
opponent_odds: float,
event_timestamp: int,
event_id: str
) -> Dict[str, Any]:
"""
Create a bet on the blockchain.
This function:
1. Builds the createBet transaction
2. Estimates gas cost
3. Signs the transaction with backend wallet
4. Submits to blockchain
5. Waits for confirmation
6. Parses event logs to get bet ID
Args:
stake_amount: Amount each party stakes (in ETH or tokens)
creator_odds: Odds multiplier for creator (e.g., 1.5)
opponent_odds: Odds multiplier for opponent (e.g., 2.0)
event_timestamp: Unix timestamp when event occurs
event_id: External event identifier for oracle
Returns:
Dict containing:
- bet_id: On-chain bet ID
- tx_hash: Transaction hash
- block_number: Block number where bet was created
- gas_used: Actual gas consumed
"""
# Pseudocode implementation:
#
# # Convert stake to wei
# stake_wei = self.web3.toWei(stake_amount, 'ether')
#
# # Build transaction
# tx = self.bet_escrow_contract.functions.createBet(
# stake_wei,
# int(creator_odds * 100), # Scale to avoid decimals
# int(opponent_odds * 100),
# event_timestamp,
# self.web3.toBytes(text=event_id)
# ).buildTransaction({
# 'from': self.account.address,
# 'gas': 200000,
# 'gasPrice': await self.web3.eth.gas_price,
# 'nonce': await self.web3.eth.get_transaction_count(self.account.address)
# })
#
# # Sign transaction
# signed_tx = self.web3.eth.account.sign_transaction(tx, self.account.key)
#
# # Send transaction
# tx_hash = await self.web3.eth.send_raw_transaction(signed_tx.rawTransaction)
#
# # Wait for receipt
# receipt = await self.web3.eth.wait_for_transaction_receipt(tx_hash)
#
# # Parse BetCreated event
# event_log = self.bet_escrow_contract.events.BetCreated().processReceipt(receipt)
# bet_id = event_log[0]['args']['betId']
#
# return {
# 'bet_id': bet_id,
# 'tx_hash': tx_hash.hex(),
# 'block_number': receipt['blockNumber'],
# 'gas_used': receipt['gasUsed']
# }
# Placeholder return for pseudocode
await asyncio.sleep(0.1) # Simulate async operation
return {
'bet_id': 42,
'tx_hash': '0xabc123...',
'block_number': 12345,
'gas_used': 150000
}
async def prepare_accept_bet_transaction(
self,
bet_id: int,
user_address: str,
stake_amount: Decimal
) -> Dict[str, Any]:
"""
Prepare acceptBet transaction for user to sign in frontend.
This returns an unsigned transaction that the frontend will
send to MetaMask for the user to sign.
Args:
bet_id: On-chain bet ID
user_address: User's wallet address (from MetaMask)
stake_amount: Stake amount to send with transaction
Returns:
Unsigned transaction dict ready for MetaMask
"""
# Pseudocode:
#
# stake_wei = self.web3.toWei(stake_amount, 'ether')
#
# tx = self.bet_escrow_contract.functions.acceptBet(bet_id).buildTransaction({
# 'from': user_address,
# 'value': stake_wei,
# 'gas': 300000,
# 'gasPrice': await self.web3.eth.gas_price,
# 'nonce': await self.web3.eth.get_transaction_count(user_address)
# })
#
# return tx # Frontend will sign this with MetaMask
await asyncio.sleep(0.1)
return {
'to': self.bet_escrow_address,
'from': user_address,
'value': int(stake_amount * 10**18), # wei
'gas': 300000,
'gasPrice': 50000000000, # 50 gwei
'data': '0x...' # Encoded function call
}
async def request_settlement(
self,
bet_id: int
) -> str:
"""
Request oracle settlement for a bet.
Called after the real-world event has occurred.
This triggers the oracle network to fetch results and settle.
Args:
bet_id: On-chain bet ID
Returns:
Transaction hash
"""
# Pseudocode:
#
# tx = self.bet_escrow_contract.functions.requestSettlement(bet_id).buildTransaction({
# 'from': self.account.address,
# 'gas': 100000,
# 'gasPrice': await self.web3.eth.gas_price,
# 'nonce': await self.web3.eth.get_transaction_count(self.account.address)
# })
#
# signed_tx = self.web3.eth.account.sign_transaction(tx, self.account.key)
# tx_hash = await self.web3.eth.send_raw_transaction(signed_tx.rawTransaction)
#
# return tx_hash.hex()
await asyncio.sleep(0.1)
return '0xdef456...'
async def settle_bet_via_oracle(
self,
request_id: int,
bet_id: int,
winner_address: str,
result_data: bytes,
signatures: list
) -> str:
"""
Submit oracle settlement to blockchain.
Called by oracle aggregator after consensus is reached.
Args:
request_id: Oracle request ID
bet_id: On-chain bet ID
winner_address: Winner's wallet address
result_data: Raw API result data
signatures: Signatures from oracle nodes
Returns:
Transaction hash
"""
# Pseudocode:
#
# tx = self.bet_oracle_contract.functions.fulfillSettlement(
# request_id,
# bet_id,
# winner_address,
# result_data,
# signatures
# ).buildTransaction({
# 'from': self.account.address,
# 'gas': 250000,
# 'gasPrice': await self.web3.eth.gas_price,
# 'nonce': await self.web3.eth.get_transaction_count(self.account.address)
# })
#
# signed_tx = self.web3.eth.account.sign_transaction(tx, self.account.key)
# tx_hash = await self.web3.eth.send_raw_transaction(signed_tx.rawTransaction)
#
# return tx_hash.hex()
await asyncio.sleep(0.1)
return '0xghi789...'
async def get_bet_from_chain(self, bet_id: int) -> Dict[str, Any]:
"""
Fetch bet details from blockchain.
Calls the getBet view function on BetEscrow contract.
Args:
bet_id: On-chain bet ID
Returns:
Dict with bet details:
- bet_id
- creator (address)
- opponent (address)
- stake_amount
- status (enum value)
- winner (address)
- created_at (timestamp)
"""
# Pseudocode:
#
# bet = await self.bet_escrow_contract.functions.getBet(bet_id).call()
#
# return {
# 'bet_id': bet[0],
# 'creator': bet[1],
# 'opponent': bet[2],
# 'stake_amount': self.web3.fromWei(bet[3], 'ether'),
# 'status': bet[4], # Enum value (0=OPEN, 1=MATCHED, etc.)
# 'creator_odds': bet[5] / 100.0,
# 'opponent_odds': bet[6] / 100.0,
# 'created_at': bet[7],
# 'event_timestamp': bet[8],
# 'event_id': bet[9],
# 'winner': bet[10],
# 'settled_at': bet[11]
# }
await asyncio.sleep(0.1)
return {
'bet_id': bet_id,
'creator': '0xAlice...',
'opponent': '0xBob...',
'stake_amount': Decimal('100'),
'status': 1, # MATCHED
'winner': None
}
async def get_user_escrow(self, user_address: str) -> Decimal:
"""
Get total amount user has locked in escrow across all bets.
Args:
user_address: User's wallet address
Returns:
Total escrow amount in ETH
"""
# Pseudocode:
#
# escrow_wei = await self.bet_escrow_contract.functions.getUserEscrow(
# user_address
# ).call()
#
# return self.web3.fromWei(escrow_wei, 'ether')
await asyncio.sleep(0.1)
return Decimal('75.00')
async def estimate_gas(
self,
transaction_type: str,
**kwargs
) -> Dict[str, Any]:
"""
Estimate gas cost for a transaction.
Args:
transaction_type: Type of transaction (create_bet, accept_bet, etc.)
**kwargs: Transaction-specific parameters
Returns:
Dict with:
- gas_limit: Estimated gas units
- gas_price: Current gas price in wei
- cost_eth: Total cost in ETH
- cost_usd: Total cost in USD (fetched from price oracle)
"""
# Pseudocode:
#
# # Build unsigned transaction
# if transaction_type == "create_bet":
# tx = self.bet_escrow_contract.functions.createBet(...).buildTransaction(...)
# elif transaction_type == "accept_bet":
# tx = self.bet_escrow_contract.functions.acceptBet(...).buildTransaction(...)
#
# # Estimate gas
# gas_limit = await self.web3.eth.estimate_gas(tx)
# gas_price = await self.web3.eth.gas_price
#
# cost_wei = gas_limit * gas_price
# cost_eth = self.web3.fromWei(cost_wei, 'ether')
#
# # Fetch ETH price from oracle
# eth_price_usd = await self.get_eth_price_usd()
# cost_usd = float(cost_eth) * eth_price_usd
#
# return {
# 'gas_limit': gas_limit,
# 'gas_price': gas_price,
# 'cost_eth': cost_eth,
# 'cost_usd': cost_usd
# }
await asyncio.sleep(0.1)
# Gas estimates for different operations
estimates = {
'create_bet': 120000,
'accept_bet': 180000,
'settle_bet': 150000
}
gas_limit = estimates.get(transaction_type, 100000)
gas_price = 50000000000 # 50 gwei
cost_wei = gas_limit * gas_price
cost_eth = Decimal(cost_wei) / Decimal(10**18)
return {
'gas_limit': gas_limit,
'gas_price': gas_price,
'cost_eth': cost_eth,
'cost_usd': float(cost_eth) * 2000 # Assume $2000/ETH
}
async def get_transaction_receipt(self, tx_hash: str) -> Optional[Dict[str, Any]]:
"""
Get transaction receipt (confirmation status).
Args:
tx_hash: Transaction hash
Returns:
Receipt dict or None if not yet mined
"""
# Pseudocode:
#
# try:
# receipt = await self.web3.eth.get_transaction_receipt(tx_hash)
# return {
# 'status': receipt['status'], # 1 = success, 0 = failed
# 'block_number': receipt['blockNumber'],
# 'gas_used': receipt['gasUsed'],
# 'logs': receipt['logs']
# }
# except TransactionNotFound:
# return None
await asyncio.sleep(0.1)
return {
'status': 1,
'block_number': 12345,
'gas_used': 150000,
'logs': []
}
# Singleton instance
_blockchain_service: Optional[BlockchainService] = None
def get_blockchain_service() -> BlockchainService:
"""
Get singleton blockchain service instance.
In production, this would load config from environment variables.
"""
global _blockchain_service
if _blockchain_service is None:
# Pseudocode: Load from config
# from app.blockchain.config import BLOCKCHAIN_CONFIG
#
# _blockchain_service = BlockchainService(
# rpc_url=BLOCKCHAIN_CONFIG['rpc_url'],
# bet_escrow_address=BLOCKCHAIN_CONFIG['bet_escrow_address'],
# bet_oracle_address=BLOCKCHAIN_CONFIG['bet_oracle_address'],
# private_key=BLOCKCHAIN_CONFIG['backend_private_key']
# )
# Placeholder for pseudocode
_blockchain_service = BlockchainService(
rpc_url="https://eth-mainnet.alchemyapi.io/v2/YOUR-API-KEY",
bet_escrow_address="0x1234567890abcdef...",
bet_oracle_address="0xfedcba0987654321...",
private_key="BACKEND_PRIVATE_KEY"
)
return _blockchain_service

View File

@ -0,0 +1,481 @@
"""
Oracle Aggregator - Consensus Coordinator
This service collects oracle submissions from multiple nodes, verifies consensus,
and submits the final result to the blockchain.
Flow:
1. Receive submissions from oracle nodes (via HTTP API)
2. Verify each submission's signature
3. Count votes for each proposed winner
4. Check if consensus threshold is met (e.g., 3 of 5 nodes)
5. Submit consensus result to BetOracle contract
6. If no consensus, mark request as disputed
NOTE: This is pseudocode/skeleton showing the architecture.
"""
import asyncio
from typing import Dict, List, Any, Optional
from collections import defaultdict
from datetime import datetime
class OracleSubmission:
"""Represents a single oracle node's submission."""
def __init__(
self,
node_id: str,
node_address: str,
winner_address: str,
result_data: Dict[str, Any],
signature: str,
submitted_at: datetime
):
self.node_id = node_id
self.node_address = node_address
self.winner_address = winner_address
self.result_data = result_data
self.signature = signature
self.submitted_at = submitted_at
class OracleAggregator:
"""
Aggregates oracle submissions and achieves consensus.
Maintains state of all active oracle requests and their submissions.
"""
def __init__(
self,
blockchain_service,
consensus_threshold: int = 3,
total_nodes: int = 5
):
"""
Initialize aggregator.
Args:
blockchain_service: Instance of BlockchainService
consensus_threshold: Minimum nodes that must agree (e.g., 3)
total_nodes: Total number of oracle nodes (e.g., 5)
"""
self.blockchain_service = blockchain_service
self.consensus_threshold = consensus_threshold
self.total_nodes = total_nodes
# Track submissions per request
# request_id => [OracleSubmission, ...]
self.submissions: Dict[int, List[OracleSubmission]] = defaultdict(list)
# Track which nodes have submitted for each request
# request_id => {node_address: bool}
self.node_submitted: Dict[int, Dict[str, bool]] = defaultdict(dict)
# Trusted oracle node addresses
self.trusted_nodes = set([
"0xNode1Address...",
"0xNode2Address...",
"0xNode3Address...",
"0xNode4Address...",
"0xNode5Address..."
])
async def receive_submission(
self,
request_id: int,
node_id: str,
node_address: str,
winner_address: str,
result_data: Dict[str, Any],
signature: str
) -> Dict[str, Any]:
"""
Receive and process an oracle node submission.
This is called by the HTTP API endpoint when nodes submit results.
Args:
request_id: Oracle request ID
node_id: Node identifier
node_address: Node's wallet address
winner_address: Proposed winner address
result_data: API result data
signature: Node's cryptographic signature
Returns:
Dict with status and any consensus result
"""
# Verify node is trusted
if node_address not in self.trusted_nodes:
return {
'status': 'error',
'message': 'Untrusted node'
}
# Verify node hasn't already submitted
if self.node_submitted[request_id].get(node_address, False):
return {
'status': 'error',
'message': 'Node already submitted'
}
# Verify signature
is_valid = await self._verify_signature(
request_id,
winner_address,
result_data,
signature,
node_address
)
if not is_valid:
return {
'status': 'error',
'message': 'Invalid signature'
}
# Store submission
submission = OracleSubmission(
node_id=node_id,
node_address=node_address,
winner_address=winner_address,
result_data=result_data,
signature=signature,
submitted_at=datetime.utcnow()
)
self.submissions[request_id].append(submission)
self.node_submitted[request_id][node_address] = True
print(f"[Aggregator] Received submission from {node_id} for request {request_id}")
print(f"[Aggregator] {len(self.submissions[request_id])}/{self.total_nodes} nodes submitted")
# Check if consensus threshold reached
if len(self.submissions[request_id]) >= self.consensus_threshold:
result = await self._check_consensus_and_settle(request_id)
return {
'status': 'success',
'consensus': result
}
return {
'status': 'pending',
'submissions_count': len(self.submissions[request_id]),
'threshold': self.consensus_threshold
}
async def _verify_signature(
self,
request_id: int,
winner_address: str,
result_data: Dict[str, Any],
signature: str,
node_address: str
) -> bool:
"""
Verify that the signature is valid for this node.
Args:
request_id: Oracle request ID
winner_address: Proposed winner
result_data: API result
signature: Signature to verify
node_address: Expected signer address
Returns:
True if signature is valid
"""
# Pseudocode:
#
# from eth_account.messages import encode_defunct, defunct_hash_message
# from eth_account import Account
# import json
# import hashlib
#
# # Reconstruct message
# message = f"{request_id}{winner_address}{json.dumps(result_data, sort_keys=True)}"
# message_hash = hashlib.sha256(message.encode()).hexdigest()
#
# # Recover signer from signature
# signable_message = encode_defunct(text=message_hash)
# recovered_address = Account.recover_message(signable_message, signature=signature)
#
# # Verify signer matches node address
# return recovered_address.lower() == node_address.lower()
# Placeholder - assume valid
return True
async def _check_consensus_and_settle(self, request_id: int) -> Dict[str, Any]:
"""
Check if consensus is reached and settle the bet.
Steps:
1. Count votes for each proposed winner
2. Find winner with most votes
3. Verify threshold met
4. Submit to blockchain if consensus
5. Mark as disputed if no consensus
Args:
request_id: Oracle request ID
Returns:
Dict with consensus result
"""
submissions = self.submissions[request_id]
# Count votes for each winner
vote_counts: Dict[str, int] = defaultdict(int)
vote_details: Dict[str, List[str]] = defaultdict(list)
for submission in submissions:
winner = submission.winner_address
vote_counts[winner] += 1
vote_details[winner].append(submission.node_id)
# Find winner with most votes
consensus_winner = max(vote_counts, key=vote_counts.get)
consensus_votes = vote_counts[consensus_winner]
print(f"[Aggregator] Vote results for request {request_id}:")
for winner, count in vote_counts.items():
nodes = vote_details[winner]
print(f" {winner}: {count} votes from {nodes}")
# Check if threshold met
if consensus_votes >= self.consensus_threshold:
print(f"[Aggregator] Consensus reached! Winner: {consensus_winner} ({consensus_votes}/{self.total_nodes} votes)")
# Get bet ID from request
# In production, fetch from blockchain or database
bet_id = await self._get_bet_id_for_request(request_id)
# Collect signatures for blockchain submission
signatures = [
sub.signature
for sub in submissions
if sub.winner_address == consensus_winner
]
# Get result data from consensus submissions
result_data = submissions[0].result_data # Use first submission's data
# Submit to blockchain
tx_hash = await self.blockchain_service.settle_bet_via_oracle(
request_id=request_id,
bet_id=bet_id,
winner_address=consensus_winner,
result_data=str(result_data).encode(),
signatures=signatures
)
print(f"[Aggregator] Submitted settlement to blockchain: {tx_hash}")
# Clean up submissions for this request
del self.submissions[request_id]
del self.node_submitted[request_id]
return {
'consensus': True,
'winner': consensus_winner,
'votes': consensus_votes,
'tx_hash': tx_hash
}
else:
print(f"[Aggregator] No consensus for request {request_id}")
print(f" Best: {consensus_winner} with {consensus_votes} votes (need {self.consensus_threshold})")
# Mark as disputed
await self._mark_as_disputed(request_id)
return {
'consensus': False,
'reason': 'Insufficient consensus',
'votes': dict(vote_counts)
}
async def _get_bet_id_for_request(self, request_id: int) -> int:
"""
Get bet ID associated with oracle request.
In production, this would query the BetOracle contract
or database to get the bet_id for this request_id.
Args:
request_id: Oracle request ID
Returns:
Bet ID
"""
# Pseudocode:
#
# request = await self.blockchain_service.bet_oracle_contract.functions.getRequest(
# request_id
# ).call()
#
# return request['betId']
# Placeholder
return 42
async def _mark_as_disputed(self, request_id: int):
"""
Mark oracle request as disputed.
Sends notification to admin for manual review.
Args:
request_id: Oracle request ID
"""
# Pseudocode:
#
# # Update database
# await db.execute(
# update(OracleRequest)
# .where(OracleRequest.request_id == request_id)
# .values(status='DISPUTED')
# )
#
# # Notify admins
# await send_admin_notification(
# "Oracle Dispute",
# f"Request {request_id} - no consensus reached"
# )
#
# # Could also call blockchain contract to mark as disputed
# await self.blockchain_service.bet_oracle_contract.functions.markAsDisputed(
# request_id
# ).send()
print(f"[Aggregator] Marked request {request_id} as disputed")
async def get_request_status(self, request_id: int) -> Dict[str, Any]:
"""
Get status of an oracle request.
Args:
request_id: Oracle request ID
Returns:
Dict with current status and submissions
"""
submissions = self.submissions.get(request_id, [])
vote_counts: Dict[str, int] = defaultdict(int)
for sub in submissions:
vote_counts[sub.winner_address] += 1
return {
'request_id': request_id,
'submissions_count': len(submissions),
'threshold': self.consensus_threshold,
'votes': dict(vote_counts),
'nodes_submitted': [sub.node_id for sub in submissions]
}
async def handle_timeout(self, request_id: int):
"""
Handle oracle request timeout.
Called if nodes fail to respond within time limit (e.g., 1 hour).
Args:
request_id: Oracle request ID
"""
submissions = self.submissions.get(request_id, [])
if len(submissions) == 0:
print(f"[Aggregator] Request {request_id} timed out with no submissions")
else:
print(f"[Aggregator] Request {request_id} timed out with {len(submissions)} submissions")
# Try to settle with available submissions
await self._check_consensus_and_settle(request_id)
# Mark as timed out on blockchain
# Pseudocode:
# await self.blockchain_service.bet_oracle_contract.functions.markAsTimedOut(
# request_id
# ).send()
# Singleton instance
_aggregator: Optional[OracleAggregator] = None
def get_oracle_aggregator(blockchain_service) -> OracleAggregator:
"""Get singleton aggregator instance."""
global _aggregator
if _aggregator is None:
_aggregator = OracleAggregator(
blockchain_service=blockchain_service,
consensus_threshold=3,
total_nodes=5
)
return _aggregator
# FastAPI endpoint example
"""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
router = APIRouter()
class OracleSubmissionRequest(BaseModel):
request_id: int
node_id: str
node_address: str
winner_address: str
result_data: dict
signature: str
@router.post("/oracle/submit")
async def submit_oracle_result(submission: OracleSubmissionRequest):
\"""
API endpoint for oracle nodes to submit results.
Called by oracle_node.py when a node has fetched and signed a result.
\"""
from app.blockchain.services.blockchain_service import get_blockchain_service
blockchain_service = get_blockchain_service()
aggregator = get_oracle_aggregator(blockchain_service)
result = await aggregator.receive_submission(
request_id=submission.request_id,
node_id=submission.node_id,
node_address=submission.node_address,
winner_address=submission.winner_address,
result_data=submission.result_data,
signature=submission.signature
)
if result['status'] == 'error':
raise HTTPException(status_code=400, detail=result['message'])
return result
@router.get("/oracle/status/{request_id}")
async def get_oracle_status(request_id: int):
\"""
Get status of an oracle request.
Shows how many nodes have submitted and current vote counts.
\"""
from app.blockchain.services.blockchain_service import get_blockchain_service
blockchain_service = get_blockchain_service()
aggregator = get_oracle_aggregator(blockchain_service)
status = await aggregator.get_request_status(request_id)
return status
"""

View File

@ -0,0 +1,471 @@
"""
Oracle Node - Decentralized Data Provider
This service acts as one node in the oracle network. It:
1. Listens for OracleRequested events from BetOracle contract
2. Fetches data from external APIs (sports, entertainment, etc.)
3. Determines the winner based on API results
4. Signs the result with node's private key
5. Submits the signed result to oracle aggregator
Multiple independent oracle nodes run this code to create a decentralized
oracle network with consensus-based settlement.
NOTE: This is pseudocode/skeleton showing the architecture.
"""
import asyncio
import hashlib
from typing import Dict, Any, Optional
from datetime import datetime
import json
class OracleNode:
"""
Oracle node for fetching and submitting external data.
Each node operates independently and signs its results.
The aggregator collects results from all nodes and achieves consensus.
"""
def __init__(
self,
node_id: str,
private_key: str,
blockchain_service,
aggregator_url: str
):
"""
Initialize oracle node.
Args:
node_id: Unique identifier for this node
private_key: Node's private key for signing results
blockchain_service: Instance of BlockchainService
aggregator_url: URL of oracle aggregator service
"""
self.node_id = node_id
self.private_key = private_key
self.blockchain_service = blockchain_service
self.aggregator_url = aggregator_url
self.is_running = False
# API adapters for different event types
self.api_adapters = {
'sports': self._fetch_sports_result,
'entertainment': self._fetch_entertainment_result,
'politics': self._fetch_politics_result,
'custom': self._fetch_custom_result
}
async def start(self):
"""
Start the oracle node.
Continuously listens for OracleRequested events.
"""
self.is_running = True
print(f"Oracle node {self.node_id} started")
try:
while self.is_running:
await self._listen_for_oracle_requests()
await asyncio.sleep(5)
except Exception as e:
print(f"Oracle node error: {e}")
self.is_running = False
async def stop(self):
"""Stop the oracle node."""
self.is_running = False
print(f"Oracle node {self.node_id} stopped")
async def _listen_for_oracle_requests(self):
"""
Listen for OracleRequested events from blockchain.
When an event is detected, process the oracle request.
"""
# Pseudocode:
#
# # Create event filter for OracleRequested
# event_filter = self.blockchain_service.bet_oracle_contract.events.OracleRequested.create_filter(
# fromBlock='latest'
# )
#
# # Get new events
# events = event_filter.get_new_entries()
#
# for event in events:
# request_id = event['args']['requestId']
# bet_id = event['args']['betId']
# event_id = event['args']['eventId']
# api_endpoint = event['args']['apiEndpoint']
#
# print(f"[Oracle {self.node_id}] Received request {request_id} for bet {bet_id}")
#
# # Process request asynchronously
# asyncio.create_task(
# self._process_oracle_request(request_id, bet_id, event_id, api_endpoint)
# )
# Placeholder
await asyncio.sleep(0.1)
async def _process_oracle_request(
self,
request_id: int,
bet_id: int,
event_id: str,
api_endpoint: str
):
"""
Process an oracle request.
Steps:
1. Fetch bet details from blockchain
2. Call external API
3. Parse result to determine winner
4. Sign the result
5. Submit to aggregator
Args:
request_id: Oracle request ID
bet_id: On-chain bet ID
event_id: External event identifier
api_endpoint: API URL to fetch result from
"""
try:
# Get bet details
bet = await self.blockchain_service.get_bet_from_chain(bet_id)
# Determine API adapter type from event_id
# event_id format: "sports:nfl-super-bowl-2024"
adapter_type = event_id.split(':')[0] if ':' in event_id else 'custom'
# Fetch API result
api_result = await self._fetch_api_result(adapter_type, api_endpoint, event_id)
if not api_result:
print(f"[Oracle {self.node_id}] Failed to fetch API result for {event_id}")
return
# Determine winner from API result
winner_address = await self._determine_winner(api_result, bet)
if not winner_address:
print(f"[Oracle {self.node_id}] Could not determine winner from API result")
return
# Sign the result
signature = self._sign_result(request_id, winner_address, api_result)
# Submit to aggregator
await self._submit_to_aggregator(
request_id,
winner_address,
api_result,
signature
)
print(f"[Oracle {self.node_id}] Submitted result for request {request_id}")
except Exception as e:
print(f"[Oracle {self.node_id}] Error processing request {request_id}: {e}")
async def _fetch_api_result(
self,
adapter_type: str,
api_endpoint: str,
event_id: str
) -> Optional[Dict[str, Any]]:
"""
Fetch result from external API.
Args:
adapter_type: Type of API (sports, entertainment, etc.)
api_endpoint: API URL
event_id: Event identifier
Returns:
Parsed API response dict or None if failed
"""
adapter_function = self.api_adapters.get(adapter_type, self._fetch_custom_result)
return await adapter_function(api_endpoint, event_id)
async def _fetch_sports_result(
self,
api_endpoint: str,
event_id: str
) -> Optional[Dict[str, Any]]:
"""
Fetch sports event result from ESPN API or similar.
Example ESPN API response:
{
"event_id": "nfl-super-bowl-2024",
"status": "final",
"winner": {
"team_name": "San Francisco 49ers",
"score": 24
},
"loser": {
"team_name": "Kansas City Chiefs",
"score": 21
}
}
Args:
api_endpoint: ESPN API URL
event_id: Event identifier
Returns:
Parsed result dict
"""
# Pseudocode:
#
# import httpx
#
# async with httpx.AsyncClient() as client:
# response = await client.get(api_endpoint, timeout=10.0)
#
# if response.status_code != 200:
# return None
#
# data = response.json()
#
# # Extract winner from response
# # Path depends on API structure
# winner_team = data['events'][0]['competitions'][0]['winner']['team']['displayName']
#
# return {
# 'event_id': event_id,
# 'winner': winner_team,
# 'status': 'final',
# 'timestamp': datetime.utcnow().isoformat()
# }
# Placeholder for pseudocode
await asyncio.sleep(0.1)
return {
'event_id': event_id,
'winner': 'San Francisco 49ers',
'status': 'final'
}
async def _fetch_entertainment_result(
self,
api_endpoint: str,
event_id: str
) -> Optional[Dict[str, Any]]:
"""
Fetch entertainment event result (awards, box office, etc.).
Example Oscars API response:
{
"year": 2024,
"category": "Best Picture",
"winner": "Oppenheimer",
"nominees": ["Oppenheimer", "Killers of the Flower Moon", ...]
}
"""
# Pseudocode:
#
# async with httpx.AsyncClient() as client:
# response = await client.get(api_endpoint, timeout=10.0)
# data = response.json()
#
# winner = data['categories']['best_picture']['winner']
#
# return {
# 'event_id': event_id,
# 'winner': winner,
# 'category': 'Best Picture'
# }
await asyncio.sleep(0.1)
return {
'event_id': event_id,
'winner': 'Oppenheimer',
'category': 'Best Picture'
}
async def _fetch_politics_result(
self,
api_endpoint: str,
event_id: str
) -> Optional[Dict[str, Any]]:
"""Fetch political event result (elections, votes, etc.)."""
await asyncio.sleep(0.1)
return {
'event_id': event_id,
'winner': 'Candidate A'
}
async def _fetch_custom_result(
self,
api_endpoint: str,
event_id: str
) -> Optional[Dict[str, Any]]:
"""Fetch result from custom user-specified API."""
await asyncio.sleep(0.1)
return {
'event_id': event_id,
'result': 'Custom result'
}
async def _determine_winner(
self,
api_result: Dict[str, Any],
bet: Dict[str, Any]
) -> Optional[str]:
"""
Map API result to bet participant address.
Compares API result with bet positions to determine winner.
Args:
api_result: Result from external API
bet: Bet details from blockchain
Returns:
Winner's wallet address or None if cannot determine
"""
# Pseudocode:
#
# # Get creator and opponent positions from bet metadata (stored off-chain)
# bet_metadata = await get_bet_metadata_from_db(bet['bet_id'])
#
# creator_position = bet_metadata['creator_position'] # e.g., "49ers win"
# opponent_position = bet_metadata['opponent_position'] # e.g., "Chiefs win"
#
# api_winner = api_result.get('winner')
#
# # Simple string matching (production would use NLP/fuzzy matching)
# if "49ers" in creator_position and "49ers" in api_winner:
# return bet['creator']
# elif "Chiefs" in creator_position and "Chiefs" in api_winner:
# return bet['creator']
# else:
# return bet['opponent']
# Placeholder - assume creator won
return bet['creator']
def _sign_result(
self,
request_id: int,
winner_address: str,
result_data: Dict[str, Any]
) -> str:
"""
Sign the oracle result with node's private key.
This proves that this specific oracle node submitted this result.
Args:
request_id: Oracle request ID
winner_address: Winner's address
result_data: API result data
Returns:
Hex-encoded signature
"""
# Pseudocode:
#
# from eth_account.messages import encode_defunct
# from eth_account import Account
#
# # Create message hash
# message = f"{request_id}{winner_address}{json.dumps(result_data, sort_keys=True)}"
# message_hash = hashlib.sha256(message.encode()).hexdigest()
#
# # Sign with private key
# signable_message = encode_defunct(text=message_hash)
# signed_message = Account.sign_message(signable_message, private_key=self.private_key)
#
# return signed_message.signature.hex()
# Placeholder
message = f"{request_id}{winner_address}{json.dumps(result_data, sort_keys=True)}"
signature_hash = hashlib.sha256(message.encode()).hexdigest()
return f"0x{signature_hash}"
async def _submit_to_aggregator(
self,
request_id: int,
winner_address: str,
result_data: Dict[str, Any],
signature: str
):
"""
Submit signed result to oracle aggregator.
The aggregator collects results from all nodes and checks consensus.
Args:
request_id: Oracle request ID
winner_address: Winner's address
result_data: API result data
signature: Node's signature
"""
# Pseudocode:
#
# import httpx
#
# payload = {
# 'request_id': request_id,
# 'node_id': self.node_id,
# 'node_address': self.node_address, # Derived from private_key
# 'winner_address': winner_address,
# 'result_data': result_data,
# 'signature': signature
# }
#
# async with httpx.AsyncClient() as client:
# response = await client.post(
# f"{self.aggregator_url}/oracle/submit",
# json=payload,
# timeout=10.0
# )
#
# if response.status_code == 200:
# print(f"[Oracle {self.node_id}] Result submitted successfully")
# else:
# print(f"[Oracle {self.node_id}] Failed to submit: {response.text}")
print(f"[Oracle {self.node_id}] Submitting result to aggregator...")
await asyncio.sleep(0.1)
# Example: Running multiple oracle nodes
async def start_oracle_node(node_id: str, private_key: str):
"""
Start an oracle node instance.
In production, you would run 3-5 nodes on different servers
with different API keys and infrastructure for redundancy.
Example:
# Node 1 (AWS us-east-1)
await start_oracle_node("oracle-node-1", "PRIVATE_KEY_1")
# Node 2 (GCP us-west-2)
await start_oracle_node("oracle-node-2", "PRIVATE_KEY_2")
# Node 3 (Azure eu-west-1)
await start_oracle_node("oracle-node-3", "PRIVATE_KEY_3")
"""
from .blockchain_service import get_blockchain_service
blockchain_service = get_blockchain_service()
node = OracleNode(
node_id=node_id,
private_key=private_key,
blockchain_service=blockchain_service,
aggregator_url="https://aggregator.h2h.com"
)
await node.start()

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

@ -0,0 +1,14 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
DATABASE_URL: str = "sqlite+aiosqlite:///./data/h2h.db"
JWT_SECRET: str = "your-secret-key-change-in-production-min-32-chars"
JWT_ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
model_config = SettingsConfigDict(env_file=".env", extra="allow")
settings = Settings()

View File

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

@ -0,0 +1,79 @@
from sqlalchemy import select, or_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models import Bet, BetStatus, BetCategory
from app.schemas.bet import BetCreate
async def get_bet_by_id(db: AsyncSession, bet_id: int) -> Bet | None:
result = await db.execute(
select(Bet)
.options(
selectinload(Bet.creator),
selectinload(Bet.opponent)
)
.where(Bet.id == bet_id)
)
return result.scalar_one_or_none()
async def get_open_bets(
db: AsyncSession,
skip: int = 0,
limit: int = 20,
category: BetCategory | None = None,
) -> list[Bet]:
query = select(Bet).where(Bet.status == BetStatus.OPEN)
if category:
query = query.where(Bet.category == category)
query = query.options(
selectinload(Bet.creator)
).offset(skip).limit(limit).order_by(Bet.created_at.desc())
result = await db.execute(query)
return list(result.scalars().all())
async def get_user_bets(
db: AsyncSession,
user_id: int,
status: BetStatus | None = None,
) -> list[Bet]:
query = select(Bet).where(
or_(Bet.creator_id == user_id, Bet.opponent_id == user_id)
)
if status:
query = query.where(Bet.status == status)
query = query.options(
selectinload(Bet.creator),
selectinload(Bet.opponent)
).order_by(Bet.created_at.desc())
result = await db.execute(query)
return list(result.scalars().all())
async def create_bet(db: AsyncSession, bet_data: BetCreate, user_id: int) -> Bet:
bet = Bet(
creator_id=user_id,
title=bet_data.title,
description=bet_data.description,
category=bet_data.category,
event_name=bet_data.event_name,
event_date=bet_data.event_date,
creator_position=bet_data.creator_position,
opponent_position=bet_data.opponent_position,
creator_odds=bet_data.creator_odds,
opponent_odds=bet_data.opponent_odds,
stake_amount=bet_data.stake_amount,
visibility=bet_data.visibility,
expires_at=bet_data.expires_at,
)
db.add(bet)
await db.flush()
await db.refresh(bet)
return bet

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

@ -0,0 +1,56 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import User, Wallet
from app.schemas.user import UserCreate
from app.utils.security import get_password_hash
from decimal import Decimal
async def get_user_by_id(db: AsyncSession, user_id: int) -> User | None:
result = await db.execute(select(User).where(User.id == user_id))
return result.scalar_one_or_none()
async def get_user_by_email(db: AsyncSession, email: str) -> User | None:
result = await db.execute(select(User).where(User.email == email))
return result.scalar_one_or_none()
async def get_user_by_username(db: AsyncSession, username: str) -> User | None:
result = await db.execute(select(User).where(User.username == username))
return result.scalar_one_or_none()
async def create_user(db: AsyncSession, user_data: UserCreate) -> User:
user = User(
email=user_data.email,
username=user_data.username,
password_hash=get_password_hash(user_data.password),
display_name=user_data.display_name or user_data.username,
)
db.add(user)
await db.flush()
# Create wallet for user
wallet = Wallet(
user_id=user.id,
balance=Decimal("0.00"),
escrow=Decimal("0.00"),
)
db.add(wallet)
await db.flush()
await db.refresh(user)
return user
async def update_user_stats(db: AsyncSession, user_id: int, won: bool) -> None:
user = await get_user_by_id(db, user_id)
if user:
user.total_bets += 1
if won:
user.wins += 1
else:
user.losses += 1
user.win_rate = user.wins / user.total_bets if user.total_bets > 0 else 0.0
await db.flush()

View File

@ -0,0 +1,54 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from decimal import Decimal
from app.models import Wallet, Transaction, TransactionType, TransactionStatus
async def get_user_wallet(db: AsyncSession, user_id: int) -> Wallet | None:
result = await db.execute(
select(Wallet).where(Wallet.user_id == user_id)
)
return result.scalar_one_or_none()
async def get_wallet_transactions(
db: AsyncSession,
user_id: int,
limit: int = 50,
offset: int = 0
) -> list[Transaction]:
result = await db.execute(
select(Transaction)
.where(Transaction.user_id == user_id)
.order_by(Transaction.created_at.desc())
.limit(limit)
.offset(offset)
)
return list(result.scalars().all())
async def create_transaction(
db: AsyncSession,
user_id: int,
wallet_id: int,
transaction_type: TransactionType,
amount: Decimal,
balance_after: Decimal,
description: str,
reference_id: int | None = None,
status: TransactionStatus = TransactionStatus.COMPLETED,
) -> Transaction:
transaction = Transaction(
user_id=user_id,
wallet_id=wallet_id,
type=transaction_type,
amount=amount,
balance_after=balance_after,
reference_id=reference_id,
description=description,
status=status,
)
db.add(transaction)
await db.flush()
return transaction

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

@ -0,0 +1,38 @@
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
engine = create_async_engine(
settings.DATABASE_URL,
echo=True,
connect_args={"check_same_thread": False} if "sqlite" in settings.DATABASE_URL else {}
)
async_session = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False
)
class Base(DeclarativeBase):
pass
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with async_session() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
async def init_db():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)

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

@ -0,0 +1,47 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
from app.database import init_db
from app.routers import auth, users, wallet, bets, websocket
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
await init_db()
yield
# Shutdown
app = FastAPI(
title="H2H Betting Platform API",
description="Peer-to-peer betting platform MVP",
version="1.0.0",
lifespan=lifespan
)
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include routers
app.include_router(auth.router)
app.include_router(users.router)
app.include_router(wallet.router)
app.include_router(bets.router)
app.include_router(websocket.router)
@app.get("/")
async def root():
return {"message": "H2H Betting Platform API", "version": "1.0.0"}
@app.get("/health")
async def health():
return {"status": "healthy"}

View File

@ -0,0 +1,19 @@
from app.models.user import User, UserStatus
from app.models.wallet import Wallet
from app.models.transaction import Transaction, TransactionType, TransactionStatus
from app.models.bet import Bet, BetProposal, BetCategory, BetStatus, BetVisibility, ProposalStatus
__all__ = [
"User",
"UserStatus",
"Wallet",
"Transaction",
"TransactionType",
"TransactionStatus",
"Bet",
"BetProposal",
"BetCategory",
"BetStatus",
"BetVisibility",
"ProposalStatus",
]

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

@ -0,0 +1,103 @@
from sqlalchemy import ForeignKey, Numeric, String, DateTime, Enum, Float
from sqlalchemy.orm import Mapped, mapped_column, relationship
from datetime import datetime
from decimal import Decimal
import enum
from app.database import Base
class BetCategory(enum.Enum):
SPORTS = "sports"
ESPORTS = "esports"
POLITICS = "politics"
ENTERTAINMENT = "entertainment"
CUSTOM = "custom"
class BetStatus(enum.Enum):
OPEN = "open"
MATCHED = "matched"
IN_PROGRESS = "in_progress"
PENDING_RESULT = "pending_result"
COMPLETED = "completed"
CANCELLED = "cancelled"
DISPUTED = "disputed"
class BetVisibility(enum.Enum):
PUBLIC = "public"
PRIVATE = "private"
FRIENDS_ONLY = "friends_only"
class Bet(Base):
__tablename__ = "bets"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
creator_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
opponent_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
title: Mapped[str] = mapped_column(String(200))
description: Mapped[str] = mapped_column(String(2000))
category: Mapped[BetCategory] = mapped_column(Enum(BetCategory))
# Event info
event_name: Mapped[str] = mapped_column(String(200))
event_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# Terms
creator_position: Mapped[str] = mapped_column(String(500))
opponent_position: Mapped[str] = mapped_column(String(500))
creator_odds: Mapped[float] = mapped_column(Float, default=1.0)
opponent_odds: Mapped[float] = mapped_column(Float, default=1.0)
# Stake
stake_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2))
currency: Mapped[str] = mapped_column(String(3), default="USD")
status: Mapped[BetStatus] = mapped_column(Enum(BetStatus), default=BetStatus.OPEN)
visibility: Mapped[BetVisibility] = mapped_column(Enum(BetVisibility), default=BetVisibility.PUBLIC)
# Blockchain integration
blockchain_bet_id: Mapped[int | None] = mapped_column(nullable=True)
blockchain_tx_hash: Mapped[str | None] = mapped_column(String(66), nullable=True)
blockchain_status: Mapped[str | None] = mapped_column(String(20), nullable=True)
# Result
winner_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
settled_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
settled_by: Mapped[str | None] = mapped_column(String(20), nullable=True)
expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
creator: Mapped["User"] = relationship(back_populates="created_bets", foreign_keys=[creator_id])
opponent: Mapped["User"] = relationship(back_populates="accepted_bets", foreign_keys=[opponent_id])
proposals: Mapped[list["BetProposal"]] = relationship(back_populates="bet")
class ProposalStatus(enum.Enum):
PENDING = "pending"
ACCEPTED = "accepted"
REJECTED = "rejected"
EXPIRED = "expired"
class BetProposal(Base):
__tablename__ = "bet_proposals"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
bet_id: Mapped[int] = mapped_column(ForeignKey("bets.id"))
proposer_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
proposed_stake: Mapped[Decimal] = mapped_column(Numeric(12, 2))
proposed_creator_odds: Mapped[float] = mapped_column(Float)
proposed_opponent_odds: Mapped[float] = mapped_column(Float)
message: Mapped[str | None] = mapped_column(String(500), nullable=True)
status: Mapped[ProposalStatus] = mapped_column(Enum(ProposalStatus), default=ProposalStatus.PENDING)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
expires_at: Mapped[datetime] = mapped_column(DateTime)
# Relationships
bet: Mapped["Bet"] = relationship(back_populates="proposals")

View File

@ -0,0 +1,42 @@
from sqlalchemy import ForeignKey, Numeric, String, DateTime, Enum, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship
from datetime import datetime
from decimal import Decimal
import enum
from app.database import Base
class TransactionType(enum.Enum):
DEPOSIT = "deposit"
WITHDRAWAL = "withdrawal"
BET_PLACED = "bet_placed"
BET_WON = "bet_won"
BET_LOST = "bet_lost"
BET_CANCELLED = "bet_cancelled"
ESCROW_LOCK = "escrow_lock"
ESCROW_RELEASE = "escrow_release"
class TransactionStatus(enum.Enum):
PENDING = "pending"
COMPLETED = "completed"
FAILED = "failed"
class Transaction(Base):
__tablename__ = "transactions"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
wallet_id: Mapped[int] = mapped_column(ForeignKey("wallets.id"))
type: Mapped[TransactionType] = mapped_column(Enum(TransactionType))
amount: Mapped[Decimal] = mapped_column(Numeric(12, 2))
balance_after: Mapped[Decimal] = mapped_column(Numeric(12, 2))
reference_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
description: Mapped[str] = mapped_column(String(500))
status: Mapped[TransactionStatus] = mapped_column(Enum(TransactionStatus), default=TransactionStatus.COMPLETED)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
user: Mapped["User"] = relationship(back_populates="transactions")
wallet: Mapped["Wallet"] = relationship(back_populates="transactions")

View File

@ -0,0 +1,41 @@
from sqlalchemy import String, DateTime, Enum, Float, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship
from datetime import datetime
import enum
from app.database import Base
class UserStatus(enum.Enum):
ACTIVE = "active"
SUSPENDED = "suspended"
PENDING_VERIFICATION = "pending_verification"
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
username: Mapped[str] = mapped_column(String(50), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255))
# Profile fields
display_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
avatar_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
bio: Mapped[str | None] = mapped_column(String(500), nullable=True)
# Stats
total_bets: Mapped[int] = mapped_column(Integer, default=0)
wins: Mapped[int] = mapped_column(Integer, default=0)
losses: Mapped[int] = mapped_column(Integer, default=0)
win_rate: Mapped[float] = mapped_column(Float, default=0.0)
status: Mapped[UserStatus] = mapped_column(Enum(UserStatus), default=UserStatus.ACTIVE)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
wallet: Mapped["Wallet"] = relationship(back_populates="user", uselist=False)
created_bets: Mapped[list["Bet"]] = relationship(back_populates="creator", foreign_keys="Bet.creator_id")
accepted_bets: Mapped[list["Bet"]] = relationship(back_populates="opponent", foreign_keys="Bet.opponent_id")
transactions: Mapped[list["Transaction"]] = relationship(back_populates="user")

View File

@ -0,0 +1,22 @@
from sqlalchemy import ForeignKey, Numeric, String, DateTime
from sqlalchemy.orm import Mapped, mapped_column, relationship
from datetime import datetime
from decimal import Decimal
from app.database import Base
class Wallet(Base):
__tablename__ = "wallets"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), unique=True)
balance: Mapped[Decimal] = mapped_column(Numeric(12, 2), default=Decimal("0.00"))
escrow: Mapped[Decimal] = mapped_column(Numeric(12, 2), default=Decimal("0.00"))
blockchain_escrow: Mapped[Decimal] = mapped_column(Numeric(18, 8), default=Decimal("0.00000000"))
currency: Mapped[str] = mapped_column(String(3), default="USD")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
user: Mapped["User"] = relationship(back_populates="wallet")
transactions: Mapped[list["Transaction"]] = relationship(back_populates="wallet")

View File

View File

@ -0,0 +1,82 @@
from fastapi import APIRouter, Depends, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.ext.asyncio import AsyncSession
from jose import JWTError
from app.database import get_db
from app.schemas.user import UserCreate, UserLogin, TokenResponse, UserResponse
from app.services.auth_service import register_user, login_user
from app.crud.user import get_user_by_id
from app.utils.security import decode_token
from app.utils.exceptions import UnauthorizedError
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
async def get_current_user(
token: str = Depends(oauth2_scheme),
db: AsyncSession = Depends(get_db)
):
try:
payload = decode_token(token)
user_id: str = payload.get("sub")
if user_id is None:
raise UnauthorizedError()
except JWTError:
raise UnauthorizedError()
user = await get_user_by_id(db, int(user_id))
if user is None:
raise UnauthorizedError()
return user
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
async def register(
user_data: UserCreate,
db: AsyncSession = Depends(get_db)
):
return await register_user(db, user_data)
@router.post("/login", response_model=TokenResponse)
async def login(
login_data: UserLogin,
db: AsyncSession = Depends(get_db)
):
return await login_user(db, login_data)
@router.get("/me", response_model=UserResponse)
async def get_current_user_info(
current_user = Depends(get_current_user)
):
return current_user
@router.post("/refresh", response_model=TokenResponse)
async def refresh_token(
token: str,
db: AsyncSession = Depends(get_db)
):
try:
payload = decode_token(token)
user_id: str = payload.get("sub")
if user_id is None:
raise UnauthorizedError()
except JWTError:
raise UnauthorizedError()
user = await get_user_by_id(db, int(user_id))
if user is None:
raise UnauthorizedError()
from app.utils.security import create_access_token, create_refresh_token
access_token = create_access_token({"sub": str(user.id)})
new_refresh_token = create_refresh_token({"sub": str(user.id)})
return TokenResponse(
access_token=access_token,
refresh_token=new_refresh_token,
)

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

@ -0,0 +1,173 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.schemas.bet import BetCreate, BetUpdate, BetResponse, BetDetailResponse, SettleBetRequest
from app.routers.auth import get_current_user
from app.crud.bet import get_bet_by_id, get_open_bets, get_user_bets, create_bet
from app.services.bet_service import accept_bet, settle_bet, cancel_bet
from app.models import User, BetCategory, BetStatus
from app.utils.exceptions import BetNotFoundError, NotBetParticipantError
router = APIRouter(prefix="/api/v1/bets", tags=["bets"])
@router.get("", response_model=list[BetResponse])
async def list_bets(
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
category: BetCategory | None = None,
db: AsyncSession = Depends(get_db)
):
bets = await get_open_bets(db, skip=skip, limit=limit, category=category)
return bets
@router.post("", response_model=BetResponse)
async def create_new_bet(
bet_data: BetCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
bet = await create_bet(db, bet_data, current_user.id)
await db.commit()
bet = await get_bet_by_id(db, bet.id)
return bet
@router.get("/{bet_id}", response_model=BetDetailResponse)
async def get_bet(
bet_id: int,
db: AsyncSession = Depends(get_db)
):
bet = await get_bet_by_id(db, bet_id)
if not bet:
raise BetNotFoundError()
return bet
@router.put("/{bet_id}", response_model=BetResponse)
async def update_bet(
bet_id: int,
bet_data: BetUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
bet = await get_bet_by_id(db, bet_id)
if not bet:
raise BetNotFoundError()
if bet.creator_id != current_user.id:
raise NotBetParticipantError()
if bet.status != BetStatus.OPEN:
raise ValueError("Cannot update non-open bet")
# Update fields
if bet_data.title is not None:
bet.title = bet_data.title
if bet_data.description is not None:
bet.description = bet_data.description
if bet_data.event_date is not None:
bet.event_date = bet_data.event_date
if bet_data.creator_position is not None:
bet.creator_position = bet_data.creator_position
if bet_data.opponent_position is not None:
bet.opponent_position = bet_data.opponent_position
if bet_data.stake_amount is not None:
bet.stake_amount = bet_data.stake_amount
if bet_data.creator_odds is not None:
bet.creator_odds = bet_data.creator_odds
if bet_data.opponent_odds is not None:
bet.opponent_odds = bet_data.opponent_odds
if bet_data.expires_at is not None:
bet.expires_at = bet_data.expires_at
await db.commit()
await db.refresh(bet)
return bet
@router.delete("/{bet_id}")
async def delete_bet(
bet_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
await cancel_bet(db, bet_id, current_user.id)
return {"message": "Bet cancelled successfully"}
@router.post("/{bet_id}/accept", response_model=BetResponse)
async def accept_bet_route(
bet_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
bet = await accept_bet(db, bet_id, current_user.id)
return bet
@router.post("/{bet_id}/settle", response_model=BetDetailResponse)
async def settle_bet_route(
bet_id: int,
settle_data: SettleBetRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
bet = await settle_bet(db, bet_id, settle_data.winner_id, current_user.id)
return bet
@router.get("/my/created", response_model=list[BetResponse])
async def get_my_created_bets(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.models import Bet
result = await db.execute(
select(Bet)
.where(Bet.creator_id == current_user.id)
.options(selectinload(Bet.creator), selectinload(Bet.opponent))
.order_by(Bet.created_at.desc())
)
return list(result.scalars().all())
@router.get("/my/accepted", response_model=list[BetResponse])
async def get_my_accepted_bets(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.models import Bet
result = await db.execute(
select(Bet)
.where(Bet.opponent_id == current_user.id)
.options(selectinload(Bet.creator), selectinload(Bet.opponent))
.order_by(Bet.created_at.desc())
)
return list(result.scalars().all())
@router.get("/my/active", response_model=list[BetResponse])
async def get_my_active_bets(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
bets = await get_user_bets(db, current_user.id)
active_bets = [bet for bet in bets if bet.status in [BetStatus.MATCHED, BetStatus.IN_PROGRESS]]
return active_bets
@router.get("/my/history", response_model=list[BetDetailResponse])
async def get_my_bet_history(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
bets = await get_user_bets(db, current_user.id, status=BetStatus.COMPLETED)
return bets

View File

@ -0,0 +1,62 @@
from fastapi import APIRouter, Depends, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.schemas.user import UserResponse, UserUpdate, UserStats
from app.routers.auth import get_current_user
from app.crud.user import get_user_by_id
from app.crud.bet import get_user_bets
from app.models import User, BetStatus
from app.utils.exceptions import UserNotFoundError
router = APIRouter(prefix="/api/v1/users", tags=["users"])
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(
user_id: int,
db: AsyncSession = Depends(get_db)
):
user = await get_user_by_id(db, user_id)
if not user:
raise UserNotFoundError()
return user
@router.put("/me", response_model=UserResponse)
async def update_current_user(
user_data: UserUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
if user_data.display_name is not None:
current_user.display_name = user_data.display_name
if user_data.avatar_url is not None:
current_user.avatar_url = user_data.avatar_url
if user_data.bio is not None:
current_user.bio = user_data.bio
await db.commit()
await db.refresh(current_user)
return current_user
@router.get("/{user_id}/stats", response_model=UserStats)
async def get_user_stats(
user_id: int,
db: AsyncSession = Depends(get_db)
):
user = await get_user_by_id(db, user_id)
if not user:
raise UserNotFoundError()
# Get active bets count
user_bets = await get_user_bets(db, user_id)
active_bets = sum(1 for bet in user_bets if bet.status in [BetStatus.MATCHED, BetStatus.IN_PROGRESS])
return UserStats(
total_bets=user.total_bets,
wins=user.wins,
losses=user.losses,
win_rate=user.win_rate,
active_bets=active_bets,
)

View File

@ -0,0 +1,50 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.schemas.wallet import WalletResponse, DepositRequest, WithdrawalRequest, TransactionResponse
from app.routers.auth import get_current_user
from app.crud.wallet import get_user_wallet, get_wallet_transactions
from app.services.wallet_service import deposit_funds, withdraw_funds
from app.models import User
router = APIRouter(prefix="/api/v1/wallet", tags=["wallet"])
@router.get("", response_model=WalletResponse)
async def get_wallet(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
wallet = await get_user_wallet(db, current_user.id)
return wallet
@router.post("/deposit", response_model=WalletResponse)
async def deposit(
deposit_data: DepositRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
wallet = await deposit_funds(db, current_user.id, deposit_data.amount)
return wallet
@router.post("/withdraw", response_model=WalletResponse)
async def withdraw(
withdrawal_data: WithdrawalRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
wallet = await withdraw_funds(db, current_user.id, withdrawal_data.amount)
return wallet
@router.get("/transactions", response_model=list[TransactionResponse])
async def get_transactions(
limit: int = 50,
offset: int = 0,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
transactions = await get_wallet_transactions(db, current_user.id, limit, offset)
return transactions

View File

@ -0,0 +1,43 @@
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
from typing import Dict
import json
router = APIRouter(tags=["websocket"])
# Store active connections
active_connections: Dict[int, WebSocket] = {}
@router.websocket("/api/v1/ws")
async def websocket_endpoint(websocket: WebSocket, token: str = Query(...)):
await websocket.accept()
# In a real implementation, you would validate the token here
# For MVP, we'll accept all connections
user_id = 1 # Placeholder
active_connections[user_id] = websocket
try:
while True:
data = await websocket.receive_text()
# Handle incoming messages if needed
except WebSocketDisconnect:
if user_id in active_connections:
del active_connections[user_id]
async def broadcast_event(event_type: str, data: dict, user_ids: list[int] = None):
"""Broadcast an event to specific users or all connected users"""
message = json.dumps({
"type": event_type,
"data": data
})
if user_ids:
for user_id in user_ids:
if user_id in active_connections:
await active_connections[user_id].send_text(message)
else:
for connection in active_connections.values():
await connection.send_text(message)

View File

@ -0,0 +1,47 @@
from app.schemas.user import (
UserCreate,
UserLogin,
UserUpdate,
UserSummary,
UserResponse,
UserStats,
TokenResponse,
TokenData,
)
from app.schemas.wallet import (
WalletResponse,
DepositRequest,
WithdrawalRequest,
TransactionResponse,
)
from app.schemas.bet import (
BetCreate,
BetUpdate,
BetResponse,
BetDetailResponse,
SettleBetRequest,
ProposalCreate,
ProposalResponse,
)
__all__ = [
"UserCreate",
"UserLogin",
"UserUpdate",
"UserSummary",
"UserResponse",
"UserStats",
"TokenResponse",
"TokenData",
"WalletResponse",
"DepositRequest",
"WithdrawalRequest",
"TransactionResponse",
"BetCreate",
"BetUpdate",
"BetResponse",
"BetDetailResponse",
"SettleBetRequest",
"ProposalCreate",
"ProposalResponse",
]

View File

@ -0,0 +1,89 @@
from pydantic import BaseModel, Field, ConfigDict
from decimal import Decimal
from datetime import datetime
from app.models.bet import BetCategory, BetStatus, BetVisibility, ProposalStatus
from app.schemas.user import UserSummary
class BetCreate(BaseModel):
title: str = Field(..., min_length=5, max_length=200)
description: str = Field(..., max_length=2000)
category: BetCategory
event_name: str = Field(..., max_length=200)
event_date: datetime | None = None
creator_position: str = Field(..., max_length=500)
opponent_position: str = Field(..., max_length=500)
stake_amount: Decimal = Field(..., gt=0, le=10000)
creator_odds: float = Field(default=1.0, gt=0)
opponent_odds: float = Field(default=1.0, gt=0)
visibility: BetVisibility = BetVisibility.PUBLIC
expires_at: datetime | None = None
class BetUpdate(BaseModel):
title: str | None = Field(None, min_length=5, max_length=200)
description: str | None = Field(None, max_length=2000)
event_date: datetime | None = None
creator_position: str | None = Field(None, max_length=500)
opponent_position: str | None = Field(None, max_length=500)
stake_amount: Decimal | None = Field(None, gt=0, le=10000)
creator_odds: float | None = Field(None, gt=0)
opponent_odds: float | None = Field(None, gt=0)
expires_at: datetime | None = None
class BetResponse(BaseModel):
id: int
title: str
description: str
category: BetCategory
event_name: str
event_date: datetime | None
creator_position: str
opponent_position: str
creator_odds: float
opponent_odds: float
stake_amount: Decimal
currency: str
status: BetStatus
visibility: BetVisibility
creator: UserSummary
opponent: UserSummary | None
expires_at: datetime | None
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class BetDetailResponse(BetResponse):
winner_id: int | None
settled_at: datetime | None
settled_by: str | None
class SettleBetRequest(BaseModel):
winner_id: int
class ProposalCreate(BaseModel):
proposed_stake: Decimal = Field(..., gt=0, le=10000)
proposed_creator_odds: float = Field(..., gt=0)
proposed_opponent_odds: float = Field(..., gt=0)
message: str | None = Field(None, max_length=500)
expires_at: datetime
class ProposalResponse(BaseModel):
id: int
bet_id: int
proposer_id: int
proposed_stake: Decimal
proposed_creator_odds: float
proposed_opponent_odds: float
message: str | None
status: ProposalStatus
created_at: datetime
expires_at: datetime
model_config = ConfigDict(from_attributes=True)

View File

@ -0,0 +1,67 @@
from pydantic import BaseModel, EmailStr, Field, ConfigDict
from datetime import datetime
from app.models.user import UserStatus
class UserCreate(BaseModel):
email: EmailStr
username: str = Field(..., min_length=3, max_length=50)
password: str = Field(..., min_length=8)
display_name: str | None = None
class UserLogin(BaseModel):
email: EmailStr
password: str
class UserUpdate(BaseModel):
display_name: str | None = None
avatar_url: str | None = None
bio: str | None = None
class UserSummary(BaseModel):
id: int
username: str
display_name: str | None
avatar_url: str | None
model_config = ConfigDict(from_attributes=True)
class UserResponse(BaseModel):
id: int
email: str
username: str
display_name: str | None
avatar_url: str | None
bio: str | None
total_bets: int
wins: int
losses: int
win_rate: float
status: UserStatus
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class UserStats(BaseModel):
total_bets: int
wins: int
losses: int
win_rate: float
active_bets: int
model_config = ConfigDict(from_attributes=True)
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
class TokenData(BaseModel):
user_id: int | None = None

View File

@ -0,0 +1,38 @@
from pydantic import BaseModel, Field, ConfigDict
from decimal import Decimal
from datetime import datetime
from app.models.transaction import TransactionType, TransactionStatus
class WalletResponse(BaseModel):
id: int
user_id: int
balance: Decimal
escrow: Decimal
currency: str
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class DepositRequest(BaseModel):
amount: Decimal = Field(..., gt=0, le=10000)
class WithdrawalRequest(BaseModel):
amount: Decimal = Field(..., gt=0)
class TransactionResponse(BaseModel):
id: int
user_id: int
type: TransactionType
amount: Decimal
balance_after: Decimal
reference_id: int | None
description: str
status: TransactionStatus
created_at: datetime
model_config = ConfigDict(from_attributes=True)

View File

View File

@ -0,0 +1,50 @@
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import User
from app.schemas.user import UserCreate, UserLogin, TokenResponse
from app.crud.user import create_user, get_user_by_email, get_user_by_username
from app.utils.security import verify_password, create_access_token, create_refresh_token
from app.utils.exceptions import InvalidCredentialsError, UserAlreadyExistsError
async def register_user(db: AsyncSession, user_data: UserCreate) -> TokenResponse:
# Check if user already exists
existing_user = await get_user_by_email(db, user_data.email)
if existing_user:
raise UserAlreadyExistsError("Email already registered")
existing_username = await get_user_by_username(db, user_data.username)
if existing_username:
raise UserAlreadyExistsError("Username already taken")
# Create user
user = await create_user(db, user_data)
await db.commit()
# Generate tokens
access_token = create_access_token({"sub": str(user.id)})
refresh_token = create_refresh_token({"sub": str(user.id)})
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
)
async def login_user(db: AsyncSession, login_data: UserLogin) -> TokenResponse:
# Get user by email
user = await get_user_by_email(db, login_data.email)
if not user:
raise InvalidCredentialsError()
# Verify password
if not verify_password(login_data.password, user.password_hash):
raise InvalidCredentialsError()
# Generate tokens
access_token = create_access_token({"sub": str(user.id)})
refresh_token = create_refresh_token({"sub": str(user.id)})
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
)

View File

@ -0,0 +1,178 @@
from sqlalchemy.ext.asyncio import AsyncSession
from datetime import datetime
from app.models import Bet, BetStatus, TransactionType
from app.crud.bet import get_bet_by_id
from app.crud.wallet import get_user_wallet, create_transaction
from app.crud.user import update_user_stats
from app.utils.exceptions import (
BetNotFoundError,
BetNotAvailableError,
CannotAcceptOwnBetError,
InsufficientFundsError,
NotBetParticipantError,
)
async def accept_bet(db: AsyncSession, bet_id: int, user_id: int) -> Bet:
from sqlalchemy.orm import selectinload
# Use transaction for atomic operations
async with db.begin_nested():
# Get and lock the bet
bet = await db.get(Bet, bet_id, with_for_update=True)
if not bet or bet.status != BetStatus.OPEN:
raise BetNotAvailableError()
if bet.creator_id == user_id:
raise CannotAcceptOwnBetError()
# Get user's wallet and verify funds
user_wallet = await get_user_wallet(db, user_id)
if not user_wallet or user_wallet.balance < bet.stake_amount:
raise InsufficientFundsError()
# Get creator's wallet and lock their funds too
creator_wallet = await get_user_wallet(db, bet.creator_id)
if not creator_wallet or creator_wallet.balance < bet.stake_amount:
raise BetNotAvailableError()
# Lock funds in escrow for both parties
user_wallet.balance -= bet.stake_amount
user_wallet.escrow += bet.stake_amount
creator_wallet.balance -= bet.stake_amount
creator_wallet.escrow += bet.stake_amount
# Update bet
bet.opponent_id = user_id
bet.status = BetStatus.MATCHED
# Create transaction records
await create_transaction(
db=db,
user_id=user_id,
wallet_id=user_wallet.id,
transaction_type=TransactionType.ESCROW_LOCK,
amount=-bet.stake_amount,
balance_after=user_wallet.balance,
reference_id=bet.id,
description=f"Escrow for bet: {bet.title}",
)
await create_transaction(
db=db,
user_id=bet.creator_id,
wallet_id=creator_wallet.id,
transaction_type=TransactionType.ESCROW_LOCK,
amount=-bet.stake_amount,
balance_after=creator_wallet.balance,
reference_id=bet.id,
description=f"Escrow for bet: {bet.title}",
)
await db.commit()
# Refresh and eagerly load relationships
from sqlalchemy import select
result = await db.execute(
select(Bet)
.where(Bet.id == bet_id)
.options(selectinload(Bet.creator), selectinload(Bet.opponent))
)
bet = result.scalar_one()
return bet
async def settle_bet(
db: AsyncSession,
bet_id: int,
winner_id: int,
settler_id: int
) -> Bet:
async with db.begin_nested():
bet = await get_bet_by_id(db, bet_id)
if not bet:
raise BetNotFoundError()
# Verify settler is a participant
if settler_id not in [bet.creator_id, bet.opponent_id]:
raise NotBetParticipantError()
# Verify winner is a participant
if winner_id not in [bet.creator_id, bet.opponent_id]:
raise ValueError("Invalid winner")
# Determine loser
loser_id = bet.opponent_id if winner_id == bet.creator_id else bet.creator_id
# Get wallets
winner_wallet = await get_user_wallet(db, winner_id)
loser_wallet = await get_user_wallet(db, loser_id)
if not winner_wallet or not loser_wallet:
raise ValueError("Wallet not found")
# Calculate payout (winner gets both stakes)
total_payout = bet.stake_amount * 2
# Release escrow and distribute funds
winner_wallet.escrow -= bet.stake_amount
winner_wallet.balance += total_payout
loser_wallet.escrow -= bet.stake_amount
# Update bet
bet.winner_id = winner_id
bet.status = BetStatus.COMPLETED
bet.settled_at = datetime.utcnow()
bet.settled_by = "participant"
# Create transaction records
await create_transaction(
db=db,
user_id=winner_id,
wallet_id=winner_wallet.id,
transaction_type=TransactionType.BET_WON,
amount=total_payout,
balance_after=winner_wallet.balance,
reference_id=bet.id,
description=f"Won bet: {bet.title}",
)
await create_transaction(
db=db,
user_id=loser_id,
wallet_id=loser_wallet.id,
transaction_type=TransactionType.BET_LOST,
amount=-bet.stake_amount,
balance_after=loser_wallet.balance,
reference_id=bet.id,
description=f"Lost bet: {bet.title}",
)
# Update user stats
await update_user_stats(db, winner_id, won=True)
await update_user_stats(db, loser_id, won=False)
await db.commit()
await db.refresh(bet)
return bet
async def cancel_bet(db: AsyncSession, bet_id: int, user_id: int) -> Bet:
async with db.begin_nested():
bet = await get_bet_by_id(db, bet_id)
if not bet:
raise BetNotFoundError()
if bet.creator_id != user_id:
raise NotBetParticipantError()
if bet.status != BetStatus.OPEN:
raise BetNotAvailableError()
bet.status = BetStatus.CANCELLED
await db.commit()
await db.refresh(bet)
return bet

View File

@ -0,0 +1,52 @@
from sqlalchemy.ext.asyncio import AsyncSession
from decimal import Decimal
from app.models import Wallet, TransactionType
from app.crud.wallet import get_user_wallet, create_transaction
from app.utils.exceptions import InsufficientFundsError
async def deposit_funds(db: AsyncSession, user_id: int, amount: Decimal) -> Wallet:
wallet = await get_user_wallet(db, user_id)
if not wallet:
raise ValueError("Wallet not found")
wallet.balance += amount
await create_transaction(
db=db,
user_id=user_id,
wallet_id=wallet.id,
transaction_type=TransactionType.DEPOSIT,
amount=amount,
balance_after=wallet.balance,
description=f"Deposit of ${amount}",
)
await db.commit()
await db.refresh(wallet)
return wallet
async def withdraw_funds(db: AsyncSession, user_id: int, amount: Decimal) -> Wallet:
wallet = await get_user_wallet(db, user_id)
if not wallet:
raise ValueError("Wallet not found")
if wallet.balance < amount:
raise InsufficientFundsError()
wallet.balance -= amount
await create_transaction(
db=db,
user_id=user_id,
wallet_id=wallet.id,
transaction_type=TransactionType.WITHDRAWAL,
amount=-amount,
balance_after=wallet.balance,
description=f"Withdrawal of ${amount}",
)
await db.commit()
await db.refresh(wallet)
return wallet

View File

View File

@ -0,0 +1,50 @@
from fastapi import HTTPException, status
class BetNotFoundError(HTTPException):
def __init__(self):
super().__init__(status_code=status.HTTP_404_NOT_FOUND, detail="Bet not found")
class InsufficientFundsError(HTTPException):
def __init__(self):
super().__init__(status_code=status.HTTP_400_BAD_REQUEST, detail="Insufficient funds")
class BetNotAvailableError(HTTPException):
def __init__(self):
super().__init__(status_code=status.HTTP_400_BAD_REQUEST, detail="Bet is no longer available")
class CannotAcceptOwnBetError(HTTPException):
def __init__(self):
super().__init__(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot accept your own bet")
class UnauthorizedError(HTTPException):
def __init__(self, detail: str = "Not authorized"):
super().__init__(status_code=status.HTTP_401_UNAUTHORIZED, detail=detail)
class UserNotFoundError(HTTPException):
def __init__(self):
super().__init__(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
class InvalidCredentialsError(HTTPException):
def __init__(self):
super().__init__(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
class UserAlreadyExistsError(HTTPException):
def __init__(self, detail: str = "User already exists"):
super().__init__(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)
class NotBetParticipantError(HTTPException):
def __init__(self):
super().__init__(status_code=status.HTTP_403_FORBIDDEN, detail="You are not a participant in this bet")

View File

@ -0,0 +1,38 @@
from datetime import datetime, timedelta
from typing import Any
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
def create_access_token(data: dict[str, Any], expires_delta: timedelta | None = None) -> str:
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
return encoded_jwt
def create_refresh_token(data: dict[str, Any]) -> str:
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
return encoded_jwt
def decode_token(token: str) -> dict[str, Any]:
return jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM])

0
backend/data/.gitkeep Normal file
View File

13
backend/requirements.txt Normal file
View File

@ -0,0 +1,13 @@
fastapi==0.109.0
uvicorn[standard]==0.27.0
sqlalchemy[asyncio]==2.0.25
aiosqlite==0.19.0
pydantic==2.5.3
pydantic-settings==2.1.0
python-jose[cryptography]==3.3.0
passlib==1.7.4
bcrypt==4.0.1
python-multipart==0.0.6
websockets==12.0
alembic==1.13.1
httpx==0.26.0

125
backend/seed_data.py Normal file
View File

@ -0,0 +1,125 @@
import asyncio
from decimal import Decimal
from datetime import datetime, timedelta
from app.database import async_session, init_db
from app.models import User, Wallet, Bet, BetCategory, BetStatus, BetVisibility
from app.utils.security import get_password_hash
async def seed():
print("Initializing database...")
await init_db()
async with async_session() as db:
print("Creating test users...")
users = [
User(
email="alice@example.com",
username="alice",
password_hash=get_password_hash("password123"),
display_name="Alice Smith"
),
User(
email="bob@example.com",
username="bob",
password_hash=get_password_hash("password123"),
display_name="Bob Jones"
),
User(
email="charlie@example.com",
username="charlie",
password_hash=get_password_hash("password123"),
display_name="Charlie Brown"
),
]
for user in users:
db.add(user)
await db.flush()
print("Creating wallets...")
for user in users:
wallet = Wallet(
user_id=user.id,
balance=Decimal("1000.00"),
escrow=Decimal("0.00")
)
db.add(wallet)
print("Creating sample bets...")
bets = [
Bet(
creator_id=users[0].id,
title="Super Bowl LVIII Winner",
description="Bet on who will win Super Bowl LVIII",
category=BetCategory.SPORTS,
event_name="Super Bowl LVIII",
event_date=datetime.utcnow() + timedelta(days=30),
creator_position="Kansas City Chiefs win",
opponent_position="San Francisco 49ers win",
stake_amount=Decimal("100.00"),
creator_odds=1.5,
opponent_odds=2.0,
visibility=BetVisibility.PUBLIC,
expires_at=datetime.utcnow() + timedelta(days=7)
),
Bet(
creator_id=users[1].id,
title="League of Legends Worlds 2024",
description="Who will win the LoL World Championship?",
category=BetCategory.ESPORTS,
event_name="LoL Worlds 2024",
creator_position="T1 wins",
opponent_position="Any other team wins",
stake_amount=Decimal("50.00"),
creator_odds=1.8,
opponent_odds=1.8,
visibility=BetVisibility.PUBLIC,
expires_at=datetime.utcnow() + timedelta(days=14)
),
Bet(
creator_id=users[2].id,
title="Oscar Best Picture 2024",
description="Which film will win Best Picture at the 2024 Oscars?",
category=BetCategory.ENTERTAINMENT,
event_name="96th Academy Awards",
event_date=datetime.utcnow() + timedelta(days=60),
creator_position="Oppenheimer wins",
opponent_position="Any other film wins",
stake_amount=Decimal("25.00"),
creator_odds=1.3,
opponent_odds=3.0,
visibility=BetVisibility.PUBLIC,
expires_at=datetime.utcnow() + timedelta(days=30)
),
Bet(
creator_id=users[0].id,
title="Bitcoin Price Prediction",
description="Will Bitcoin reach $100,000 by end of Q1 2024?",
category=BetCategory.CUSTOM,
event_name="Bitcoin Price Target",
event_date=datetime.utcnow() + timedelta(days=90),
creator_position="Bitcoin will reach $100k",
opponent_position="Bitcoin won't reach $100k",
stake_amount=Decimal("75.00"),
creator_odds=2.5,
opponent_odds=1.5,
visibility=BetVisibility.PUBLIC,
expires_at=datetime.utcnow() + timedelta(days=20)
),
]
for bet in bets:
db.add(bet)
await db.commit()
print("✅ Seed data created successfully!")
print("\nTest users:")
print(" - alice@example.com / password123")
print(" - bob@example.com / password123")
print(" - charlie@example.com / password123")
print("\nEach user has $1000 starting balance")
if __name__ == "__main__":
asyncio.run(seed())