""" 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 get_async_session, 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 get_async_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 get_async_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 get_async_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 get_async_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//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 get_async_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 get_async_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 get_async_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 get_async_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 )