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