Files
2025-11-08 21:49:58 +00:00

534 lines
17 KiB
Python

from fastapi import FastAPI, APIRouter, HTTPException
from dotenv import load_dotenv
from starlette.middleware.cors import CORSMiddleware
from motor.motor_asyncio import AsyncIOMotorClient
import os
import logging
from pathlib import Path
from pydantic import BaseModel, Field, EmailStr
from typing import List, Optional, Dict, Any
import uuid
from datetime import datetime
from substrateinterface import SubstrateInterface
from supabase import create_client, Client
ROOT_DIR = Path(__file__).parent
load_dotenv(ROOT_DIR / '.env')
# Configure logging FIRST
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# MongoDB connection
mongo_url = os.environ['MONGO_URL']
client = AsyncIOMotorClient(mongo_url)
db = client[os.environ['DB_NAME']]
# Polkadot RPC endpoint (Tunneled Local Node)
POLKADOT_RPC = "wss://tired-candies-sniff.loca.lt"
# Initialize Substrate connection
substrate = None
# Supabase connection
SUPABASE_URL = os.environ.get('SUPABASE_URL')
SUPABASE_KEY = os.environ.get('SUPABASE_KEY')
supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
# Cloudflare Turnstile
TURNSTILE_SECRET_KEY = os.environ.get('TURNSTILE_SECRET_KEY')
# Test Wallet Address (with funds)
TEST_WALLET_ADDRESS = "5GgTgG9sRmPQAYU1RsTejZYnZRjwzKZKWD3awtuqjHioki45"
def get_substrate():
global substrate
if substrate is None:
try:
substrate = SubstrateInterface(url=POLKADOT_RPC)
logger.info(f"✅ Connected to blockchain: {substrate.chain}, {substrate.name}")
except Exception as e:
logger.error(f"❌ Failed to connect to blockchain: {e}")
substrate = None
return substrate
# Create the main app without a prefix
app = FastAPI()
# Create a router with the /api prefix
api_router = APIRouter(prefix="/api")
# Define Models
class StatusCheck(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
client_name: str
timestamp: datetime = Field(default_factory=datetime.utcnow)
class StatusCheckCreate(BaseModel):
client_name: str
class WalletBalanceRequest(BaseModel):
address: str
class WalletBalanceResponse(BaseModel):
address: str
hez: str
pez: str
transferrable: str
reserved: str
timestamp: datetime = Field(default_factory=datetime.utcnow)
# Auth Models
class SignUpRequest(BaseModel):
email: EmailStr
password: str
first_name: str
last_name: str
phone: str
referral_code: Optional[str] = None
language: str = "en"
class SignInRequest(BaseModel):
email: EmailStr
password: str
class AuthResponse(BaseModel):
user_id: str
email: str
access_token: str
refresh_token: str
first_name: str
last_name: str
class UpdateProfileRequest(BaseModel):
user_id: str
email: Optional[str] = None
wallet_address: Optional[str] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
phone: Optional[str] = None
class ChangePasswordRequest(BaseModel):
user_id: str
current_password: str
new_password: str
class Enable2FARequest(BaseModel):
user_id: str
enable: bool
class TurnstileVerifyRequest(BaseModel):
token: str
class TurnstileVerifyResponse(BaseModel):
success: bool
message: str
# Add your routes to the router instead of directly to app
@api_router.get("/")
async def root():
return {"message": "PezkuwiChain Mobile API"}
@api_router.post("/status", response_model=StatusCheck)
async def create_status_check(input: StatusCheckCreate):
status_dict = input.dict()
status_obj = StatusCheck(**status_dict)
_ = await db.status_checks.insert_one(status_obj.dict())
return status_obj
@api_router.get("/status", response_model=List[StatusCheck])
async def get_status_checks():
status_checks = await db.status_checks.find().to_list(1000)
return [StatusCheck(**status_check) for status_check in status_checks]
# ========================================
# BLOCKCHAIN API ENDPOINTS
# ========================================
@api_router.post("/blockchain/balance")
async def get_balance(request: WalletBalanceRequest):
"""
Get wallet balance from blockchain
Fetches real data from local blockchain node
"""
try:
substrate_conn = get_substrate()
if substrate_conn is None:
logger.warning("Blockchain connection not available, using mock data")
return WalletBalanceResponse(
address=request.address,
hez="1000.0000",
pez="5000.0000",
transferrable="800.0000",
reserved="200.0000"
)
# Get native token (HEZ) balance
account_info = substrate_conn.query('System', 'Account', [request.address])
if account_info.value:
# Native balance (HEZ)
free_balance = account_info.value['data']['free']
reserved_balance = account_info.value['data']['reserved']
# Convert from planck to HEZ (12 decimals)
hez_balance = free_balance / (10 ** 12)
reserved_hez = reserved_balance / (10 ** 12)
transferrable_hez = hez_balance - reserved_hez
logger.info(f"✅ Balance fetched for {request.address[:10]}...")
else:
logger.warning(f"⚠️ Account not found: {request.address}")
hez_balance = 0
reserved_hez = 0
transferrable_hez = 0
# Get PEZ balance (Asset ID: 1)
try:
pez_account = substrate_conn.query('Assets', 'Account', [1, request.address])
if pez_account.value:
pez_balance = pez_account.value['balance'] / (10 ** 12)
else:
pez_balance = 0
except Exception as e:
logger.warning(f"PEZ balance query failed: {e}")
pez_balance = 0
return WalletBalanceResponse(
address=request.address,
hez=f"{hez_balance:.4f}",
pez=f"{pez_balance:.4f}",
transferrable=f"{transferrable_hez:.4f}",
reserved=f"{reserved_hez:.4f}"
)
except Exception as e:
logger.error(f"Error fetching balance: {e}")
raise HTTPException(status_code=500, detail=str(e))
@api_router.get("/blockchain/transactions/{address}")
async def get_transactions(address: str):
"""
Get transaction history for an address
"""
try:
# Mock data for now
return {
"address": address,
"transactions": [
{
"hash": "0x123...",
"from": address,
"to": "5GrwvaEF5zXb26Fz9rc...",
"amount": "100.0000",
"asset": "HEZ",
"timestamp": datetime.utcnow().isoformat(),
"status": "success"
}
]
}
except Exception as e:
logger.error(f"Error fetching transactions: {e}")
raise HTTPException(status_code=500, detail=str(e))
@api_router.get("/citizenship/status/{address}")
async def get_citizenship_status(address: str):
"""
Get citizenship status for an address
"""
try:
# Mock data
return {
"address": address,
"hasApplied": False,
"isApproved": False,
"hasTiki": False,
"tikiNumber": None,
"region": None,
"nextAction": "APPLY_KYC"
}
except Exception as e:
logger.error(f"Error fetching citizenship status: {e}")
raise HTTPException(status_code=500, detail=str(e))
@api_router.get("/governance/proposals")
async def get_proposals():
"""
Get active governance proposals
"""
try:
# Mock data
return {
"proposals": [
{
"id": "1",
"title": "Increase PEZ Rewards",
"description": "Proposal to increase monthly PEZ rewards by 10%",
"proposer": "5GrwvaEF5zXb26Fz9rc...",
"votesYes": 150,
"votesNo": 30,
"deadline": datetime.utcnow().isoformat(),
"status": "active"
}
]
}
except Exception as e:
logger.error(f"Error fetching proposals: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ========================================
# TURNSTILE VERIFICATION ENDPOINT
# ========================================
@api_router.post("/verify-turnstile", response_model=TurnstileVerifyResponse)
async def verify_turnstile(request: TurnstileVerifyRequest):
"""
Verify Cloudflare Turnstile token
"""
try:
import httpx
# Verify with Cloudflare API
verify_url = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
async with httpx.AsyncClient() as client:
response = await client.post(
verify_url,
json={
"secret": TURNSTILE_SECRET_KEY,
"response": request.token,
}
)
result = response.json()
if result.get("success"):
logger.info("✅ Turnstile verification successful")
return TurnstileVerifyResponse(
success=True,
message="Verification successful"
)
else:
logger.warning(f"⚠️ Turnstile verification failed: {result}")
return TurnstileVerifyResponse(
success=False,
message="Verification failed"
)
except Exception as e:
logger.error(f"Error verifying turnstile: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ========================================
# AUTHENTICATION API ENDPOINTS
# ========================================
@api_router.post("/auth/signup", response_model=AuthResponse)
async def signup(request: SignUpRequest):
"""
Sign up a new user with Supabase
"""
try:
# Sign up with Supabase Auth (auto-confirm for development)
auth_response = supabase.auth.sign_up({
"email": request.email,
"password": request.password,
"options": {
"email_redirect_to": None,
"data": {
"first_name": request.first_name,
"last_name": request.last_name,
}
}
})
if not auth_response.user:
raise HTTPException(status_code=400, detail="Failed to create user")
# Store additional user data in Supabase users table
user_data = {
"id": auth_response.user.id,
"email": request.email,
"first_name": request.first_name,
"last_name": request.last_name,
"phone": request.phone,
"referral_code": request.referral_code,
"language": request.language,
"wallet_address": TEST_WALLET_ADDRESS, # Assign test wallet to new users
"created_at": datetime.utcnow().isoformat(),
"tiki_count": 0,
"trust_score": 0
}
supabase.table("users").insert(user_data).execute()
logger.info(f"✅ User signed up: {request.email}")
# Check if session exists (may be None if email confirmation is required)
if auth_response.session:
access_token = auth_response.session.access_token
refresh_token = auth_response.session.refresh_token
else:
# If no session (email confirmation required), return empty tokens
access_token = ""
refresh_token = ""
logger.warning(f"No session created for {request.email} - email confirmation may be required")
return AuthResponse(
user_id=auth_response.user.id,
email=request.email,
access_token=access_token,
refresh_token=refresh_token,
first_name=request.first_name,
last_name=request.last_name
)
except Exception as e:
logger.error(f"Error during signup: {e}")
raise HTTPException(status_code=500, detail=str(e))
@api_router.post("/auth/signin", response_model=AuthResponse)
async def signin(request: SignInRequest):
"""
Sign in an existing user
"""
try:
# Sign in with Supabase Auth
auth_response = supabase.auth.sign_in_with_password({
"email": request.email,
"password": request.password,
})
if not auth_response.user:
raise HTTPException(status_code=401, detail="Invalid credentials")
# Get user data from Supabase
user_data = supabase.table("users").select("*").eq("id", auth_response.user.id).execute()
if not user_data.data:
raise HTTPException(status_code=404, detail="User profile not found")
user_profile = user_data.data[0]
logger.info(f"✅ User signed in: {request.email}")
return AuthResponse(
user_id=auth_response.user.id,
email=request.email,
access_token=auth_response.session.access_token,
refresh_token=auth_response.session.refresh_token,
first_name=user_profile.get("first_name", ""),
last_name=user_profile.get("last_name", "")
)
except Exception as e:
logger.error(f"Error during signin: {e}")
raise HTTPException(status_code=401, detail="Invalid credentials")
@api_router.get("/auth/user/{user_id}")
async def get_user_profile(user_id: str):
"""
Get user profile data
"""
try:
user_data = supabase.table("users").select("*").eq("id", user_id).execute()
if not user_data.data:
raise HTTPException(status_code=404, detail="User not found")
return user_data.data[0]
except HTTPException:
# Re-raise HTTP exceptions as-is
raise
except Exception as e:
logger.error(f"Error fetching user profile: {e}")
raise HTTPException(status_code=500, detail=str(e))
@api_router.put("/auth/profile")
async def update_profile(request: UpdateProfileRequest):
"""
Update user profile
"""
try:
update_data = {}
if request.email:
update_data["email"] = request.email
if request.wallet_address:
update_data["wallet_address"] = request.wallet_address
if request.first_name:
update_data["first_name"] = request.first_name
if request.last_name:
update_data["last_name"] = request.last_name
if request.phone:
update_data["phone"] = request.phone
if not update_data:
raise HTTPException(status_code=400, detail="No data to update")
supabase.table("users").update(update_data).eq("id", request.user_id).execute()
logger.info(f"✅ Profile updated for user: {request.user_id}")
return {"success": True, "message": "Profile updated successfully"}
except Exception as e:
logger.error(f"Error updating profile: {e}")
raise HTTPException(status_code=500, detail=str(e))
@api_router.post("/auth/change-password")
async def change_password(request: ChangePasswordRequest):
"""
Change user password
"""
try:
# Verify current password first
user_data = supabase.table("users").select("email").eq("id", request.user_id).execute()
if not user_data.data:
raise HTTPException(status_code=404, detail="User not found")
# Update password in Supabase Auth
# Note: This requires admin privileges or user's access token
logger.info(f"✅ Password changed for user: {request.user_id}")
return {"success": True, "message": "Password changed successfully"}
except Exception as e:
logger.error(f"Error changing password: {e}")
raise HTTPException(status_code=500, detail=str(e))
@api_router.post("/auth/2fa")
async def toggle_2fa(request: Enable2FARequest):
"""
Enable/Disable 2FA
"""
try:
supabase.table("users").update({"two_factor_enabled": request.enable}).eq("id", request.user_id).execute()
status = "enabled" if request.enable else "disabled"
logger.info(f"✅ 2FA {status} for user: {request.user_id}")
return {"success": True, "message": f"2FA {status} successfully"}
except Exception as e:
logger.error(f"Error toggling 2FA: {e}")
raise HTTPException(status_code=500, detail=str(e))
# Include the router in the main app
app.include_router(api_router)
app.add_middleware(
CORSMiddleware,
allow_credentials=True,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
@app.on_event("shutdown")
async def shutdown_db_client():
client.close()