634 lines
24 KiB
Python
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 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/<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 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
|
|
) |