Untitled

Anonymous
plain_text
02/24/2026 6:32 AM
39.3 KB
18
Indexable
"""
Miner REST Server - REST API for miners to receive order submissions.

This module provides a REST API server for miners that:
- Accepts order submissions from external traders via HTTP POST
- Provides synchronous feedback on validator acceptance/rejection
- Follows miner's LOCAL mode pattern (in-process, no RPC)
- Direct method calls to PropNetOrderPlacer (no IPC, no separate process)

Key differences from VantaRestServer:
- Only inherits BaseRestServer (no RPC health monitoring)
- Runs in-process with miner (not spawned as separate process)
- Direct reference to PropNetOrderPlacer for synchronous processing
- Simpler architecture suitable for miner use case
"""

import asyncio
import os
import json
import time
import uuid
import aiohttp
import aiohttp
from aiohttp_socks import ProxyConnector
import requests
import bittensor as bt
from typing import Optional
from flask import jsonify, request
from bittensor_wallet import Wallet

from vali_objects.utils.vali_utils import ValiUtils
from vanta_api.base_rest_server import BaseRestServer
from vali_objects.utils.vali_bkp_utils import ValiBkpUtils
from miner_config import MinerConfig
from vali_objects.vali_dataclasses.order_signal import Signal
from vali_objects.vali_config import TradePair
from vali_objects.enums.order_type_enum import OrderType
from vali_objects.enums.execution_type_enum import ExecutionType


class MinerRestServer(BaseRestServer):
    """
    Miner REST API server with synchronous order processing via direct calls.

    Follows miner's LOCAL mode pattern:
    - In-process (no spawn_process)
    - No RPC health monitoring
    - Direct method calls to PropNetOrderPlacer

    The server provides:
    - Synchronous order submission with validator feedback
    - Legacy file-based signal reception (backward compatible)
    - Entity miner subaccount creation
    - Order status queries
    - Health check endpoint
    """

    def __init__(self, prop_net_order_placer, api_keys_file,
                 refresh_interval=15, metrics_interval_minutes=5,
                 flask_host=None, flask_port=None, slack_notifier=None, **kwargs):
        """
        Initialize miner REST server with direct PropNetOrderPlacer reference.

        Args:
            prop_net_order_placer: Direct reference to PropNetOrderPlacer instance
            api_keys_file: Path to miner API keys file
            refresh_interval: How often to check for API key changes (seconds)
            metrics_interval_minutes: How often to log API metrics (minutes)
            flask_host: Host address for Flask server (default: "0.0.0.0")
            flask_port: Port for Flask server (default: 8088)
            slack_notifier: Optional SlackNotifier for notifications
        """
        # Store direct reference to order placer (no IPC, no RPC!)
        self.order_placer = prop_net_order_placer
        self.slack_notifier = slack_notifier

        print(f"[MINER-REST-INIT] Initializing MinerRestServer...")

        # Call BaseRestServer.__init__ (Flask only, no RPC)
        super().__init__(
            api_keys_file=api_keys_file,
            service_name="MinerRestServer",
            refresh_interval=refresh_interval,
            metrics_interval_minutes=metrics_interval_minutes,
            flask_host=flask_host or "0.0.0.0",
            flask_port=flask_port or 8088,
            **kwargs
        )

        # Pre-load wallet secrets and coldkey for subaccount creation
        try:
            secrets = ValiUtils.get_secrets(secrets_path=MinerConfig.get_secrets_file_path())
            self._wallet_name = secrets.get('wallet_name')
            self._wallet_hotkey = secrets.get('wallet_hotkey')
            self._validator_url = secrets.get('validator_url')
            wallet_password = ValiUtils.get_secret('wallet_password', secrets_path=MinerConfig.get_secrets_file_path())

            wallet = Wallet(name=self._wallet_name, hotkey=self._wallet_hotkey)
            self._coldkey = wallet.get_coldkey(password=wallet_password)
            self._hotkey = wallet.hotkey
            del wallet_password
            print(f"[MINER-REST-INIT] Wallet and secrets loaded for subaccount creation")
        except Exception as e:
            bt.logging.error(f"[MINER-REST-INIT] Failed to pre-load wallet secrets: {e}. "
                             f"Subaccount creation will fall back to per-request loading.")
            self._coldkey = None
            self._hotkey = None
            self._wallet_name = None
            self._wallet_hotkey = None
            self._validator_url = None

        print(f"[MINER-REST-INIT] MinerRestServer initialized on {self.flask_host}:{self.flask_port}")

    # ============================================================================
    # ABSTRACT METHOD IMPLEMENTATIONS (from BaseRestServer)
    # ============================================================================

    def _initialize_clients(self, **kwargs):
        """
        No clients needed - we have direct reference to order placer.

        Called by BaseRestServer.__init__() but miner doesn't need RPC clients.
        """
        print(f"[MINER-REST-INIT] No RPC clients needed (direct PropNetOrderPlacer reference)")

    def _register_routes(self):
        """Register miner-specific endpoints."""
        print(f"[MINER-REST-INIT] Registering miner endpoints...")

        # Synchronous order submission (new primary endpoint)
        self.app.route("/api/submit-order", methods=["POST"])(self.submit_order_endpoint)

        # Entity miner registration
        self.app.route("/api/register-entity", methods=["POST"])(self.register_entity_endpoint)

        # Entity miner subaccount creation
        self.app.route("/api/create-subaccount", methods=["POST"])(self.create_subaccount_endpoint)

        # Order status query
        self.app.route("/api/order-status/<order_uuid>", methods=["GET"])(self.order_status_endpoint)

        # Health check
        self.app.route("/api/health", methods=["GET"])(self.health_endpoint)

        print(f"[MINER-REST-INIT] 4 miner endpoints registered ✓")

    # ============================================================================
    # ENDPOINT HANDLERS
    # ============================================================================

    def submit_order_endpoint(self):
        """
        Synchronous order submission with direct call to PropNetOrderPlacer.

        This runs in a Flask worker thread. Multiple concurrent requests
        are handled by Flask's thread pool (default 10 threads). Each thread
        submits async work to the shared event loop in PropNetOrderPlacer.

        Request body (JSON):
        {
            "order_uuid": "optional-uuid",  // Auto-generated if not provided
            "trade_pair": "BTC/USD",
            "order_type": "LONG" | "SHORT" | "FLAT",
            "leverage": 0.1,  // Exactly one of leverage, value, or quantity required
            "value": 1000.0,  // Exactly one of leverage, value, or quantity required
            "quantity": 0.5,  // Exactly one of leverage, value, or quantity required
            "execution_type": "MARKET" | "LIMIT",
            "price": 50000.0,  // Required for LIMIT orders
            "subaccount_id": "optional-subaccount-id"
        }

        Response (200 OK):
        {
            "success": true,
            "order_uuid": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
            "order_json": "...",
            "error_message": "",
            "processing_time": 1.23,
            "message": "Order successfully processed by Taoshi validator"
        }

        Response (400 Bad Request):
        {
            "success": false,
            "error": "Invalid request: missing required field 'trade_pair'"
        }

        Response (401 Unauthorized):
        {
            "error": "Unauthorized access"
        }
        """
        # 1. Validate API key
        api_key = self._get_api_key_safe()
        if not self.is_valid_api_key(api_key):
            return jsonify({'error': 'Unauthorized access'}), 401

        # 2. Parse and validate request body
        try:
            signal_data = request.get_json()
            if not signal_data:
                return jsonify({'success': False, 'error': 'Invalid request: missing JSON body'}), 400

            # Generate order_uuid if not provided
            order_uuid = signal_data.get('order_uuid', str(uuid.uuid4()))

            bt.logging.debug(f"Processing order {order_uuid}")

        except Exception as e:
            bt.logging.error(f"Error parsing request body: {e}")
            return jsonify({'success': False, 'error': f'Invalid request: {str(e)}'}), 400

        # 2.5. Validate signal data
        try:
            # Convert trade_pair string to TradePair enum
            if isinstance(signal_data.get('trade_pair'), str):
                trade_pair = TradePair.from_trade_pair_id(signal_data['trade_pair'])
            elif isinstance(signal_data.get('trade_pair'), dict):
                trade_pair = TradePair.from_trade_pair_id(signal_data['trade_pair'].get('trade_pair_id'))
            else:
                trade_pair = signal_data.get('trade_pair')  # Might already be TradePair enum

            # let Signal class model validators handle validation
            signal = Signal(
                trade_pair=trade_pair,
                order_type=OrderType.from_string(signal_data['order_type'].upper()) if 'order_type' in signal_data else None,
                leverage=float(signal_data['leverage']) if 'leverage' in signal_data else None,
                value=float(signal_data['value']) if 'value' in signal_data else None,
                quantity=float(signal_data['quantity']) if 'quantity' in signal_data else None,
                execution_type=ExecutionType.from_string(signal_data.get('execution_type', 'MARKET').upper()),
                limit_price=float(signal_data['limit_price']) if 'limit_price' in signal_data else None,
                stop_loss=float(signal_data['stop_loss']) if 'stop_loss' in signal_data else None,
                take_profit=float(signal_data['take_profit']) if 'take_profit' in signal_data else None,
                bracket_orders=signal_data.get('bracket_orders')
            )

            bt.logging.debug(f"Signal validation passed for order {order_uuid}: {signal}")

        except ValueError as e:
            bt.logging.warning(f"Signal validation failed for order {order_uuid}")
            return jsonify({
                'success': False,
                'error': f'Invalid signal data: {str(e)}'
            }), 400
        except Exception as e:
            bt.logging.error(f"Unexpected error during signal validation for order {order_uuid}")
            return jsonify({
                'success': False,
                'error': f'Signal validation error: {str(e)}'
            }), 400

        # 3. Call order_placer.process_a_signal_for_rest() directly
        try:
            bt.logging.info(f"Processing order: {signal}...")

            result = self.order_placer.process_a_signal_for_rest(
                order_uuid=order_uuid,
                signal=signal,
                subaccount_id=signal_data.get('subaccount_id', None)
            )

            bt.logging.info(f"Order {order_uuid} processed in {result.get('processing_time', 0):.2f}s: success={result.get('success')}")

            # 4. Return formatted response
            status_code = 200 if result.get('success') else 400
            return jsonify(result), status_code

        except Exception as e:
            bt.logging.error(f"Error processing order {order_uuid}: {e}")
            return jsonify({
                'success': False,
                'order_uuid': order_uuid,
                'error': f'Internal error processing order: {str(e)}'
            }), 500

    def register_entity_endpoint(self):
        """
        Entity miner registration.
        """
        start_time = time.time()

        # 1. Validate API key
        api_key = self._get_api_key_safe()
        if not self.is_valid_api_key(api_key):
            return jsonify({'error': 'Unauthorized access'}), 401

        # 2. Use cached wallet or fall back to per-request loading
        if self._coldkey and self._hotkey and self._validator_url:
            coldkey = self._coldkey
            hotkey = self._hotkey
            validator_url = self._validator_url
        else:
            try:
                secrets = ValiUtils.get_secrets(secrets_path=MinerConfig.get_secrets_file_path())
                wallet_name = secrets.get('wallet_name')
                wallet_hotkey = secrets.get('wallet_hotkey')
                wallet_password = ValiUtils.get_secret('wallet_password', secrets_path=MinerConfig.get_secrets_file_path())
                validator_url = secrets.get('validator_url')

                if not all([wallet_name, wallet_hotkey, wallet_password, validator_url]):
                    del wallet_password
                    return jsonify({
                        'status': 'error',
                        'message': 'Missing wallet configuration in secrets file'
                    }), 500

                wallet = Wallet(name=wallet_name, hotkey=wallet_hotkey)
                coldkey = wallet.get_coldkey(password=wallet_password)
                hotkey = wallet.hotkey
                del wallet_password
            except Exception as e:
                bt.logging.error(f"Error loading wallet secrets: {e}")
                return jsonify({'status': 'error', 'message': 'Failed to load wallet configuration'}), 500

        # 3. Sign message
        try:
            # Build message dict - CRITICAL: must use sort_keys=True for deterministic ordering
            message_dict = {
                "entity_coldkey": coldkey.ss58_address,
                "entity_hotkey": hotkey.ss58_address,
            }
            message = json.dumps(message_dict, sort_keys=True).encode('utf-8')

            # Sign with coldkey
            signature = coldkey.sign(message).hex()
        except Exception as e:
            bt.logging.error(f"Error signing message: {e}")
            return jsonify({'status': 'error', 'message': f'Wallet error: {str(e)}'}), 500

        # 4. Send request to validator
        try:
            payload = {
                "entity_hotkey": hotkey.ss58_address,
                "entity_coldkey": coldkey.ss58_address,
                "signature": signature,
                "version": "2.0.0",
            }

            async def register_entity():
                # 1. Initialize the Connector
                from aiohttp_socks import ProxyConnector
                connector = ProxyConnector.from_url("socks5://127.0.0.1:2001", rdns=True)

                # 2. Pass the connector to the ClientSession
                async with aiohttp.ClientSession(connector=connector) as session:
                    try:
                        # 3. Perform the POST request
                        async with session.post(
                            f"{validator_url}/entity/register",
                            json=payload,
                            headers={"Content-Type": "application/json"},
                            timeout=aiohttp.ClientTimeout(total=60)
                        ) as response:
                            return response.status, await response.json()
                            
                    except Exception as e:
                        print(f"An error occurred: {e}")
            
            status_code, response_data = asyncio.run(register_entity())

            # Return validator response
            if status_code == 200:
                return jsonify(response_data), 200
            else:
                error_message = response_data.get('error', 'Unknown error from validator')
                return jsonify({
                    'status': 'error',
                    'message': error_message
                }), status_code

        except requests.exceptions.Timeout:
            return jsonify({'status': 'error', 'message': 'Request to validator timed out'}), 504

        except requests.exceptions.ConnectionError:
            return jsonify({'status': 'error', 'message': 'Could not connect to validator'}), 503

        except Exception as e:
            return jsonify({'status': 'error', 'message': f'Validator communication error: {str(e)}'}), 500

    def create_subaccount_endpoint(self):
        """
        Entity miner subaccount creation.

        Request body (JSON):
        {
            "asset_class": "crypto" | "forex",  // Required
            "account_size": float                // Required, must be > 0
        }

        Response (200 OK):
        {
            "status": "success",
            "message": "...",
            "subaccount": {
                "subaccount_id": 0,
                "subaccount_uuid": "uuid-string",
                "synthetic_hotkey": "5xxx_0",
                "account_size": 10000.0,
                "asset_class": "crypto"
            }
        }
        """
        start_time = time.time()

        # 1. Validate API key
        api_key = self._get_api_key_safe()
        if not self.is_valid_api_key(api_key):
            return jsonify({'error': 'Unauthorized access'}), 401

        # 2. Parse request body
        try:
            request_data = request.get_json()
            if not request_data:
                return jsonify({'status': 'error', 'message': 'Invalid request: missing JSON body'}), 400

            # Validate required fields
            if "asset_class" not in request_data:
                return jsonify({'status': 'error', 'message': 'Missing required field: asset_class'}), 400

            if "account_size" not in request_data:
                return jsonify({'status': 'error', 'message': 'Missing required field: account_size'}), 400

            asset_class = request_data["asset_class"]
            admin = request_data.get("admin", False)

            # Type conversion with error handling
            try:
                account_size = float(request_data["account_size"])
            except (ValueError, TypeError):
                return jsonify({'status': 'error', 'message': 'account_size must be a number'}), 400

            # Admin flag validation
            if not isinstance(admin, bool):
                return jsonify({'status': 'error', 'message': 'admin must be a boolean'}), 400

            # Asset class validation
            if asset_class not in ["crypto", "forex"]:
                return jsonify({
                    'status': 'error',
                    'message': f"Invalid asset_class: {asset_class}. Must be 'crypto' or 'forex'"
                }), 400

            # Account size validation
            if account_size <= 0:
                return jsonify({'status': 'error', 'message': 'account_size must be positive'}), 400

        except Exception as e:
            bt.logging.error(f"Error parsing request body: {e}")
            return jsonify({'status': 'error', 'message': f'Invalid request: {str(e)}'}), 400

        # 3. Use cached wallet or fall back to per-request loading
        if self._coldkey and self._hotkey and self._validator_url:
            coldkey = self._coldkey
            hotkey = self._hotkey
            validator_url = self._validator_url
        else:
            try:
                secrets = ValiUtils.get_secrets(secrets_path=MinerConfig.get_secrets_file_path())
                wallet_name = secrets.get('wallet_name')
                wallet_hotkey = secrets.get('wallet_hotkey')
                wallet_password = ValiUtils.get_secret('wallet_password', secrets_path=MinerConfig.get_secrets_file_path())
                validator_url = secrets.get('validator_url')

                if not all([wallet_name, wallet_hotkey, wallet_password, validator_url]):
                    del wallet_password
                    return jsonify({
                        'status': 'error',
                        'message': 'Missing wallet configuration in secrets file'
                    }), 500

                wallet = Wallet(name=wallet_name, hotkey=wallet_hotkey)
                coldkey = wallet.get_coldkey(password=wallet_password)
                hotkey = wallet.hotkey
                del wallet_password
            except Exception as e:
                bt.logging.error(f"Error loading wallet secrets: {e}")
                return jsonify({'status': 'error', 'message': 'Failed to load wallet configuration'}), 500

        # 4. Sign message
        try:
            # Build message dict - CRITICAL: must use sort_keys=True for deterministic ordering
            message_dict = {
                "account_size": account_size,
                "admin": admin,
                "asset_class": asset_class,
                "entity_coldkey": coldkey.ss58_address,
                "entity_hotkey": hotkey.ss58_address
            }
            print(f"{coldkey.ss58_address}, {hotkey.ss58_address}, {account_size}, {asset_class}")  # Debug log for critical signing inputs
            message = json.dumps(message_dict, sort_keys=True).encode('utf-8')

            # Sign with coldkey
            signature = coldkey.sign(message).hex()
        except Exception as e:
            bt.logging.error(f"Error signing message: {e}")
            return jsonify({'status': 'error', 'message': f'Wallet error: {str(e)}'}), 500

        # 5. Send request to validator
        try:
            payload = {
                "entity_hotkey": hotkey.ss58_address,
                "entity_coldkey": coldkey.ss58_address,
                "account_size": account_size,
                "asset_class": asset_class,
                "admin": admin,
                "signature": signature,
                "version": "2.0.0"
            }

            print("Hi")
            async def create_subaccount():
                # 1. Initialize the Connector
                from aiohttp_socks import ProxyConnector
                connector = ProxyConnector.from_url("socks5://127.0.0.1:2001", rdns=True)

                # 2. Pass the connector to the ClientSession
                async with aiohttp.ClientSession(connector=connector) as session:
                    try:
                        # 3. Perform the POST request
                        async with session.post(
                            f"{validator_url}/entity/create-subaccount",
                            json=payload,
                            headers={"Content-Type": "application/json"},
                            timeout=aiohttp.ClientTimeout(total=60)
                        ) as response:
                            return response.status, await response.json()
                            
                    except Exception as e:
                        print(f"An error occurred: {e}")
            
            status_code, response_data = asyncio.run(create_subaccount())


            # response = requests.post(
            #     f"{validator_url}/entity/create-subaccount",
            #     json=payload,
            #     headers={"Content-Type": "application/json"},
            #     timeout=60
            # )
            elapsed_s = time.time() - start_time

            # Parse response
            # try:
            #     response_data = asyncio.run(response.json())
            # except json.JSONDecodeError:
            #     return jsonify({
            #         'status': 'error',
            #         'message': 'Invalid JSON response from validator'
            #     }), 500
            print(response_data)

            # Return validator response
            if status_code == 200:
                if self.slack_notifier:
                    subaccount = response_data.get('subaccount', {})
                    from datetime import datetime, timezone
                    timestamp = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')
                    self.slack_notifier.send_message(
                        f"✅ Subaccount created successfully!\n"
                        f"ID: {subaccount.get('subaccount_id')}\n"
                        f"UUID: {subaccount.get('subaccount_uuid')}\n"
                        f"Synthetic Hotkey: {subaccount.get('synthetic_hotkey')}\n"
                        f"Asset Class: {subaccount.get('asset_class')}\n"
                        f"Account Size: ${subaccount.get('account_size'):,.2f}\n"
                        f"Message: {response_data.get('message', '')}\n"
                        f"Created: {timestamp}\n"
                        f"Time: {elapsed_s:.2f}s",
                        level="success",
                        bypass_cooldown=True
                    )
                return jsonify(response_data), 200
            else:
                error_message = response_data.get('message', 'Unknown error from validator')
                if self.slack_notifier:
                    self.slack_notifier.send_message(
                        f"❌ Subaccount creation failed\n"
                        f"Asset Class: {asset_class}\n"
                        f"Account Size: ${account_size:,.2f}\n"
                        f"Error: {error_message}",
                        level="error"
                    )
                return jsonify({
                    'status': 'error',
                    'message': error_message
                }), status_code

        except requests.exceptions.Timeout:
            if self.slack_notifier:
                self.slack_notifier.send_message(
                    f"❌ Subaccount creation failed\n"
                    f"Asset Class: {asset_class}\n"
                    f"Account Size: ${account_size:,.2f}\n"
                    f"Error: Request to validator timed out",
                    level="error"
                )
            return jsonify({'status': 'error', 'message': 'Request to validator timed out'}), 504

        except requests.exceptions.ConnectionError:
            if self.slack_notifier:
                self.slack_notifier.send_message(
                    f"❌ Subaccount creation failed\n"
                    f"Asset Class: {asset_class}\n"
                    f"Account Size: ${account_size:,.2f}\n"
                    f"Error: Could not connect to validator",
                    level="error"
                )
            return jsonify({'status': 'error', 'message': 'Could not connect to validator'}), 503

        except Exception as e:
            bt.logging.error(f"Error communicating with validator: {e}")
            if self.slack_notifier:
                self.slack_notifier.send_message(
                    f"❌ Subaccount creation failed\n"
                    f"Asset Class: {asset_class}\n"
                    f"Account Size: ${account_size:,.2f}\n"
                    f"Error: {str(e)}",
                    level="error"
                )
            return jsonify({'status': 'error', 'message': f'Validator communication error: {str(e)}'}), 500

    def order_status_endpoint(self, order_uuid):
        """
        Query order status by UUID.

        Checks processed_signals/ and failed_signals/ directories for order details.

        Response (200 OK):
        {
            "order_uuid": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
            "status": "completed" | "failed" | "not_found",
            "details": {...}  // Signal data if found
        }
        """
        # 1. Validate API key
        api_key = self._get_api_key_safe()
        if not self.is_valid_api_key(api_key):
            return jsonify({'error': 'Unauthorized access'}), 401

        # 2. Search for order in processed_signals/ and failed_signals/
        try:
            processed_dir = MinerConfig.get_miner_processed_signals_dir()
            failed_dir = MinerConfig.get_miner_failed_signals_dir()

            # Check processed_signals/
            processed_file = os.path.join(processed_dir, order_uuid)
            if os.path.exists(processed_file):
                signal_data = ValiBkpUtils.get_file(processed_file)
                return jsonify({
                    'order_uuid': order_uuid,
                    'status': 'completed',
                    'details': json.loads(signal_data)
                }), 200

            # Check failed_signals/
            failed_file = os.path.join(failed_dir, order_uuid)
            if os.path.exists(failed_file):
                signal_data = ValiBkpUtils.get_file(failed_file)
                return jsonify({
                    'order_uuid': order_uuid,
                    'status': 'failed',
                    'details': json.loads(signal_data)
                }), 200

            # Not found
            return jsonify({
                'order_uuid': order_uuid,
                'status': 'not_found',
                'message': 'Order not found in processed or failed signals'
            }), 404

        except Exception as e:
            bt.logging.error(f"Error querying order status: {e}")
            return jsonify({'error': f'Internal error: {str(e)}'}), 500

    def health_endpoint(self):
        """
        Server health check.

        Response (200 OK):
        {
            "status": "healthy",
            "service": "MinerRestServer",
            "timestamp": 1234567890.123
        }
        """
        return jsonify({
            'status': 'healthy',
            'service': 'MinerRestServer',
            'timestamp': time.time()
        }), 200
Editor is loading...
Leave a Comment