uploader-bot/app/api/routes/blockchain_routes.py

634 lines
24 KiB
Python

"""
Blockchain operations routes for TON integration with async wallet management.
Provides secure transaction handling, balance queries, and smart contract interactions.
"""
import asyncio
from datetime import datetime, timedelta
from decimal import Decimal
from typing import Dict, List, Optional, Any
from uuid import UUID, uuid4
from sanic import Blueprint, Request, response
from sanic.response import JSONResponse
from sqlalchemy import select, update, and_
from app.core.config import get_settings
from app.core.database import db_manager, get_cache_manager
from app.core.logging import get_logger
from app.core.models.user import User
from app.api.middleware import require_auth, validate_request, rate_limit
from app.core.validation import BlockchainTransactionSchema
from app.core.background.ton_service import TONService
# Initialize blueprint
blockchain_bp = Blueprint("blockchain", url_prefix="/api/v1/blockchain")
logger = get_logger(__name__)
settings = get_settings()
@blockchain_bp.route("/wallet/balance", methods=["GET"])
@rate_limit(limit=100, window=3600) # 100 balance checks per hour
@require_auth(permissions=["blockchain.read"])
async def get_wallet_balance(request: Request) -> JSONResponse:
"""
Get user wallet balance with caching for performance.
Args:
request: Sanic request object
Returns:
JSONResponse: Wallet balance information
"""
try:
user_id = request.ctx.user.id
cache_manager = get_cache_manager()
# Try cache first
balance_key = f"wallet_balance:{user_id}"
cached_balance = await cache_manager.get(balance_key)
if cached_balance:
return response.json({
"balance": cached_balance,
"cached": True,
"updated_at": cached_balance.get("updated_at")
})
async with db_manager.get_session() as session:
# Get user wallet address
user_stmt = select(User).where(User.id == user_id)
user_result = await session.execute(user_stmt)
user = user_result.scalar_one_or_none()
if not user or not user.wallet_address:
return response.json(
{"error": "Wallet not configured", "code": "WALLET_NOT_CONFIGURED"},
status=400
)
# Get balance from TON service
ton_service = TONService()
balance_data = await ton_service.get_wallet_balance(user.wallet_address)
if balance_data.get("error"):
return response.json(
{"error": balance_data["error"], "code": "BALANCE_FETCH_FAILED"},
status=500
)
# Cache balance for 5 minutes
balance_response = {
"address": user.wallet_address,
"balance_nanotons": balance_data["balance"],
"balance_tons": str(Decimal(balance_data["balance"]) / Decimal("1000000000")),
"last_transaction_lt": balance_data.get("last_transaction_lt"),
"updated_at": datetime.utcnow().isoformat()
}
await cache_manager.set(balance_key, balance_response, ttl=300)
await logger.ainfo(
"Wallet balance retrieved",
user_id=str(user_id),
address=user.wallet_address,
balance=balance_data["balance"]
)
return response.json({
"balance": balance_response,
"cached": False
})
except Exception as e:
await logger.aerror(
"Failed to get wallet balance",
user_id=str(request.ctx.user.id),
error=str(e)
)
return response.json(
{"error": "Failed to get balance", "code": "BALANCE_FAILED"},
status=500
)
@blockchain_bp.route("/wallet/transactions", methods=["GET"])
@rate_limit(limit=50, window=3600) # 50 transaction history requests per hour
@require_auth(permissions=["blockchain.read"])
async def get_wallet_transactions(request: Request) -> JSONResponse:
"""
Get wallet transaction history with pagination.
Args:
request: Sanic request object
Returns:
JSONResponse: Transaction history
"""
try:
user_id = request.ctx.user.id
# Parse query parameters
limit = min(int(request.args.get("limit", 20)), 100) # Max 100 transactions
offset = max(int(request.args.get("offset", 0)), 0)
async with db_manager.get_session() as session:
# Get user wallet address
user_stmt = select(User).where(User.id == user_id)
user_result = await session.execute(user_stmt)
user = user_result.scalar_one_or_none()
if not user or not user.wallet_address:
return response.json(
{"error": "Wallet not configured", "code": "WALLET_NOT_CONFIGURED"},
status=400
)
# Check cache for recent transactions
cache_manager = get_cache_manager()
cache_key = f"wallet_transactions:{user_id}:{limit}:{offset}"
cached_transactions = await cache_manager.get(cache_key)
if cached_transactions:
return response.json({
"transactions": cached_transactions,
"cached": True
})
# Get transactions from TON service
ton_service = TONService()
transactions_data = await ton_service.get_wallet_transactions(
user.wallet_address,
limit=limit,
offset=offset
)
if transactions_data.get("error"):
return response.json(
{"error": transactions_data["error"], "code": "TRANSACTIONS_FETCH_FAILED"},
status=500
)
# Process and format transactions
formatted_transactions = []
for tx in transactions_data.get("transactions", []):
formatted_tx = {
"hash": tx.get("hash"),
"lt": tx.get("lt"),
"timestamp": tx.get("utime"),
"value": tx.get("value", "0"),
"value_tons": str(Decimal(tx.get("value", "0")) / Decimal("1000000000")),
"fee": tx.get("fee", "0"),
"source": tx.get("in_msg", {}).get("source"),
"destination": tx.get("out_msgs", [{}])[0].get("destination"),
"message": tx.get("in_msg", {}).get("message", ""),
"type": "incoming" if tx.get("in_msg") else "outgoing",
"status": "success" if tx.get("success") else "failed"
}
formatted_transactions.append(formatted_tx)
# Cache for 2 minutes
await cache_manager.set(cache_key, formatted_transactions, ttl=120)
return response.json({
"transactions": formatted_transactions,
"total": len(formatted_transactions),
"limit": limit,
"offset": offset,
"cached": False
})
except Exception as e:
await logger.aerror(
"Failed to get wallet transactions",
user_id=str(request.ctx.user.id),
error=str(e)
)
return response.json(
{"error": "Failed to get transactions", "code": "TRANSACTIONS_FAILED"},
status=500
)
@blockchain_bp.route("/transaction/send", methods=["POST"])
@rate_limit(limit=10, window=3600) # 10 transactions per hour
@require_auth(permissions=["blockchain.write"])
@validate_request(BlockchainTransactionSchema)
async def send_transaction(request: Request) -> JSONResponse:
"""
Send TON transaction with comprehensive validation and monitoring.
Args:
request: Sanic request with transaction data
Returns:
JSONResponse: Transaction submission result
"""
try:
user_id = request.ctx.user.id
data = request.json
async with db_manager.get_session() as session:
# Get user with wallet
user_stmt = select(User).where(User.id == user_id)
user_result = await session.execute(user_stmt)
user = user_result.scalar_one_or_none()
if not user or not user.wallet_address or not user.wallet_private_key:
return response.json(
{"error": "Wallet not properly configured", "code": "WALLET_INCOMPLETE"},
status=400
)
# Validate transaction limits
amount_nanotons = data.get("amount", 0)
max_transaction = settings.MAX_TRANSACTION_AMOUNT * 1000000000 # Convert to nanotons
if amount_nanotons > max_transaction:
return response.json(
{"error": f"Amount exceeds maximum allowed ({settings.MAX_TRANSACTION_AMOUNT} TON)",
"code": "AMOUNT_EXCEEDED"},
status=400
)
# Check daily transaction limit
cache_manager = get_cache_manager()
daily_limit_key = f"daily_transactions:{user_id}:{datetime.utcnow().date()}"
daily_amount = await cache_manager.get(daily_limit_key, default=0)
if daily_amount + amount_nanotons > settings.DAILY_TRANSACTION_LIMIT * 1000000000:
return response.json(
{"error": "Daily transaction limit exceeded", "code": "DAILY_LIMIT_EXCEEDED"},
status=429
)
# Prepare transaction
transaction_data = {
"transaction_type": data["transaction_type"],
"recipient_address": data.get("recipient_address"),
"amount": amount_nanotons,
"message": data.get("message", ""),
"sender_address": user.wallet_address
}
# Send transaction via TON service
ton_service = TONService()
tx_result = await ton_service.send_transaction(
private_key=user.wallet_private_key,
**transaction_data
)
if tx_result.get("error"):
await logger.awarning(
"Transaction failed",
user_id=str(user_id),
error=tx_result["error"],
**transaction_data
)
return response.json(
{"error": tx_result["error"], "code": "TRANSACTION_FAILED"},
status=400
)
# Update daily limit counter
await cache_manager.increment(daily_limit_key, amount_nanotons, ttl=86400)
# Store transaction record
from app.core.models.blockchain import BlockchainTransaction
async with db_manager.get_session() as session:
tx_record = BlockchainTransaction(
id=uuid4(),
user_id=user_id,
transaction_hash=tx_result["hash"],
transaction_type=data["transaction_type"],
amount=amount_nanotons,
recipient_address=data.get("recipient_address"),
sender_address=user.wallet_address,
message=data.get("message", ""),
status="pending",
network_fee=tx_result.get("fee", 0),
block_hash=tx_result.get("block_hash"),
logical_time=tx_result.get("lt")
)
session.add(tx_record)
await session.commit()
# Clear balance cache
balance_key = f"wallet_balance:{user_id}"
await cache_manager.delete(balance_key)
await logger.ainfo(
"Transaction sent successfully",
user_id=str(user_id),
transaction_hash=tx_result["hash"],
amount=amount_nanotons,
recipient=data.get("recipient_address")
)
return response.json({
"message": "Transaction sent successfully",
"transaction": {
"hash": tx_result["hash"],
"amount": amount_nanotons,
"amount_tons": str(Decimal(amount_nanotons) / Decimal("1000000000")),
"recipient": data.get("recipient_address"),
"fee": tx_result.get("fee", 0),
"status": "pending",
"timestamp": datetime.utcnow().isoformat()
}
}, status=201)
except Exception as e:
await logger.aerror(
"Failed to send transaction",
user_id=str(request.ctx.user.id),
error=str(e)
)
return response.json(
{"error": "Failed to send transaction", "code": "SEND_FAILED"},
status=500
)
@blockchain_bp.route("/transaction/<tx_hash>/status", methods=["GET"])
@rate_limit(limit=100, window=3600) # 100 status checks per hour
@require_auth(permissions=["blockchain.read"])
async def get_transaction_status(request: Request, tx_hash: str) -> JSONResponse:
"""
Get transaction status and confirmation details.
Args:
request: Sanic request object
tx_hash: Transaction hash to check
Returns:
JSONResponse: Transaction status information
"""
try:
user_id = request.ctx.user.id
# Check cache first
cache_manager = get_cache_manager()
status_key = f"tx_status:{tx_hash}"
cached_status = await cache_manager.get(status_key)
if cached_status and cached_status.get("status") in ["confirmed", "failed"]:
# Cache confirmed/failed transactions longer
return response.json(cached_status)
# Get transaction from database
async with db_manager.get_session() as session:
from app.core.models.blockchain import BlockchainTransaction
tx_stmt = select(BlockchainTransaction).where(
and_(
BlockchainTransaction.transaction_hash == tx_hash,
BlockchainTransaction.user_id == user_id
)
)
tx_result = await session.execute(tx_stmt)
tx_record = tx_result.scalar_one_or_none()
if not tx_record:
return response.json(
{"error": "Transaction not found", "code": "TRANSACTION_NOT_FOUND"},
status=404
)
# Get current status from blockchain
ton_service = TONService()
status_data = await ton_service.get_transaction_status(tx_hash)
if status_data.get("error"):
# Return database status if blockchain query fails
tx_status = {
"hash": tx_record.transaction_hash,
"status": tx_record.status,
"confirmations": 0,
"amount": tx_record.amount,
"created_at": tx_record.created_at.isoformat(),
"blockchain_error": status_data["error"]
}
else:
# Update status based on blockchain data
new_status = "confirmed" if status_data.get("confirmed") else "pending"
if status_data.get("failed"):
new_status = "failed"
tx_status = {
"hash": tx_record.transaction_hash,
"status": new_status,
"confirmations": status_data.get("confirmations", 0),
"block_hash": status_data.get("block_hash"),
"block_time": status_data.get("block_time"),
"amount": tx_record.amount,
"fee": status_data.get("fee", tx_record.network_fee),
"created_at": tx_record.created_at.isoformat(),
"confirmed_at": status_data.get("confirmed_at")
}
# Update database record if status changed
if tx_record.status != new_status:
async with db_manager.get_session() as session:
update_stmt = (
update(BlockchainTransaction)
.where(BlockchainTransaction.id == tx_record.id)
.values(
status=new_status,
confirmations=status_data.get("confirmations", 0),
confirmed_at=datetime.fromisoformat(status_data["confirmed_at"])
if status_data.get("confirmed_at") else None
)
)
await session.execute(update_stmt)
await session.commit()
# Cache status (longer for final states)
cache_ttl = 300 if tx_status["status"] == "pending" else 3600 # 5 min vs 1 hour
await cache_manager.set(status_key, tx_status, ttl=cache_ttl)
return response.json(tx_status)
except Exception as e:
await logger.aerror(
"Failed to get transaction status",
user_id=str(request.ctx.user.id),
tx_hash=tx_hash,
error=str(e)
)
return response.json(
{"error": "Failed to get transaction status", "code": "STATUS_FAILED"},
status=500
)
@blockchain_bp.route("/wallet/create", methods=["POST"])
@rate_limit(limit=1, window=86400) # 1 wallet creation per day
@require_auth(permissions=["blockchain.wallet.create"])
async def create_wallet(request: Request) -> JSONResponse:
"""
Create new TON wallet for user (one per user).
Args:
request: Sanic request object
Returns:
JSONResponse: Wallet creation result
"""
try:
user_id = request.ctx.user.id
async with db_manager.get_session() as session:
# Check if user already has a wallet
user_stmt = select(User).where(User.id == user_id)
user_result = await session.execute(user_stmt)
user = user_result.scalar_one_or_none()
if not user:
return response.json(
{"error": "User not found", "code": "USER_NOT_FOUND"},
status=404
)
if user.wallet_address:
return response.json(
{"error": "Wallet already exists", "code": "WALLET_EXISTS"},
status=400
)
# Create wallet via TON service
ton_service = TONService()
wallet_data = await ton_service.create_wallet()
if wallet_data.get("error"):
return response.json(
{"error": wallet_data["error"], "code": "WALLET_CREATION_FAILED"},
status=500
)
# Store wallet information (encrypt private key)
from app.core.security import encrypt_data
encrypted_private_key = encrypt_data(
wallet_data["private_key"],
context=f"wallet:{user_id}"
)
user.wallet_address = wallet_data["address"]
user.wallet_private_key = encrypted_private_key
user.wallet_created_at = datetime.utcnow()
await session.commit()
await logger.ainfo(
"Wallet created successfully",
user_id=str(user_id),
wallet_address=wallet_data["address"]
)
return response.json({
"message": "Wallet created successfully",
"wallet": {
"address": wallet_data["address"],
"created_at": datetime.utcnow().isoformat(),
"balance": "0",
"network": "TON"
},
"security_note": "Private key is encrypted and stored securely. Keep your account secure."
}, status=201)
except Exception as e:
await logger.aerror(
"Failed to create wallet",
user_id=str(request.ctx.user.id),
error=str(e)
)
return response.json(
{"error": "Failed to create wallet", "code": "WALLET_FAILED"},
status=500
)
@blockchain_bp.route("/stats", methods=["GET"])
@rate_limit(limit=50, window=3600) # 50 stats requests per hour
@require_auth(permissions=["blockchain.read"])
async def get_blockchain_stats(request: Request) -> JSONResponse:
"""
Get user blockchain activity statistics.
Args:
request: Sanic request object
Returns:
JSONResponse: Blockchain activity statistics
"""
try:
user_id = request.ctx.user.id
async with db_manager.get_session() as session:
from sqlalchemy import func
from app.core.models.blockchain import BlockchainTransaction
# Get transaction statistics
stats_stmt = select(
func.count(BlockchainTransaction.id).label('total_transactions'),
func.sum(BlockchainTransaction.amount).label('total_amount'),
func.sum(BlockchainTransaction.network_fee).label('total_fees')
).where(BlockchainTransaction.user_id == user_id)
stats_result = await session.execute(stats_stmt)
stats = stats_result.first()
# Get transactions by type
type_stats_stmt = select(
BlockchainTransaction.transaction_type,
func.count(BlockchainTransaction.id).label('count'),
func.sum(BlockchainTransaction.amount).label('amount')
).where(
BlockchainTransaction.user_id == user_id
).group_by(BlockchainTransaction.transaction_type)
type_result = await session.execute(type_stats_stmt)
type_stats = {
row.transaction_type: {
'count': row.count,
'total_amount': row.amount or 0
}
for row in type_result
}
# Get recent activity (last 30 days)
recent_date = datetime.utcnow() - timedelta(days=30)
recent_stmt = select(
func.count(BlockchainTransaction.id).label('recent_count'),
func.sum(BlockchainTransaction.amount).label('recent_amount')
).where(
and_(
BlockchainTransaction.user_id == user_id,
BlockchainTransaction.created_at >= recent_date
)
)
recent_result = await session.execute(recent_stmt)
recent_stats = recent_result.first()
blockchain_stats = {
"total_transactions": stats.total_transactions or 0,
"total_amount_nanotons": stats.total_amount or 0,
"total_amount_tons": str(Decimal(stats.total_amount or 0) / Decimal("1000000000")),
"total_fees_nanotons": stats.total_fees or 0,
"total_fees_tons": str(Decimal(stats.total_fees or 0) / Decimal("1000000000")),
"by_type": type_stats,
"recent_activity": {
"transactions_30d": recent_stats.recent_count or 0,
"amount_30d_nanotons": recent_stats.recent_amount or 0,
"amount_30d_tons": str(Decimal(recent_stats.recent_amount or 0) / Decimal("1000000000"))
},
"generated_at": datetime.utcnow().isoformat()
}
return response.json(blockchain_stats)
except Exception as e:
await logger.aerror(
"Failed to get blockchain stats",
user_id=str(request.ctx.user.id),
error=str(e)
)
return response.json(
{"error": "Failed to get blockchain statistics", "code": "STATS_FAILED"},
status=500
)