#!/usr/bin/env python3 """ TIME_LEDGER — Распределённый леджер Montana Protocol ===================================================== ПРИНЦИП: - Каждое начисление = подписанная транзакция - Узлы транслируют транзакции друг другу (HTTP) - Баланс = сумма всех транзакций для адреса - Конфликты: timestamp + node_priority ГАРАНТИИ: - Eventual consistency (секунды) - Нет единой точки отказа - Полный аудит всех операций - ML-DSA-65 верификация Alejandro Montana © 2026 """ import os import json import time import uuid import sqlite3 import asyncio import aiohttp import hashlib import threading import logging from pathlib import Path from datetime import datetime, timezone from typing import Dict, List, Optional, Any from dataclasses import dataclass, asdict from contextlib import contextmanager # ML-DSA-65 для подписей try: from node_crypto import sign_message, verify_signature, get_node_crypto_system ML_DSA_AVAILABLE = True except ImportError: ML_DSA_AVAILABLE = False logging.basicConfig(level=logging.INFO) logger = logging.getLogger("TIME_LEDGER") # ═══════════════════════════════════════════════════════════════════════════════ # КОНФИГУРАЦИЯ СЕТИ # ═══════════════════════════════════════════════════════════════════════════════ NODES = { "amsterdam": {"ip": "72.56.102.240", "port": 8765, "priority": 0}, "moscow": {"ip": "176.124.208.93", "port": 8765, "priority": 1}, "almaty": {"ip": "91.200.148.93", "port": 8765, "priority": 2}, "spb": {"ip": "188.225.58.98", "port": 8765, "priority": 3}, "novosibirsk": {"ip": "147.45.147.247", "port": 8765, "priority": 4}, } # Текущий узел (из ENV) CURRENT_NODE = os.getenv("MONTANA_NODE_NAME", "amsterdam") # ═══════════════════════════════════════════════════════════════════════════════ # ТРАНЗАКЦИЯ # ═══════════════════════════════════════════════════════════════════════════════ @dataclass class Transaction: """Транзакция в леджере Montana""" tx_id: str # UUID транзакции timestamp: int # Unix timestamp (ms) address: str # Адрес кошелька (address) amount: int # Сумма (положительная = credit, отрицательная = debit) tx_type: str # credit, debit, transfer_in, transfer_out node: str # Узел-источник t2_index: int # Индекс T2 слайса prev_hash: str # Хэш предыдущей TX (цепочка) signature: str # ML-DSA-65 подпись узла @classmethod def create(cls, address: str, amount: int, tx_type: str, node: str, t2_index: int, prev_hash: str, private_key: Optional[str] = None) -> "Transaction": """Создаёт и подписывает транзакцию""" tx = cls( tx_id=str(uuid.uuid4()), timestamp=int(time.time() * 1000), address=address, amount=amount, tx_type=tx_type, node=node, t2_index=t2_index, prev_hash=prev_hash, signature="" ) # Подписываем if private_key and ML_DSA_AVAILABLE: message = tx.to_sign_message() tx.signature = sign_message(private_key, message) return tx def to_sign_message(self) -> str: """Сообщение для подписи""" return f"MONTANA_TX:{self.tx_id}:{self.timestamp}:{self.address}:{self.amount}:{self.tx_type}:{self.node}:{self.t2_index}:{self.prev_hash}" def tx_hash(self) -> str: """SHA256 хэш транзакции""" data = f"{self.tx_id}:{self.timestamp}:{self.address}:{self.amount}:{self.signature}" return hashlib.sha256(data.encode()).hexdigest() def verify(self, public_key: str) -> bool: """Верифицирует подпись""" if not ML_DSA_AVAILABLE or not self.signature: return True # Без криптографии — доверяем message = self.to_sign_message() return verify_signature(public_key, message, self.signature) def to_dict(self) -> dict: return asdict(self) @classmethod def from_dict(cls, data: dict) -> "Transaction": return cls(**data) # ═══════════════════════════════════════════════════════════════════════════════ # ЛЕДЖЕР (локальная база) # ═══════════════════════════════════════════════════════════════════════════════ class TimeLedger: """ Распределённый леджер Montana Хранит все транзакции локально. Транслирует новые TX на другие узлы. Баланс = сумма всех TX для адреса. """ def __init__(self, db_path: Optional[Path] = None, node_name: str = None): self.node_name = node_name or CURRENT_NODE self.db_path = db_path or Path(__file__).parent / "data" / "ledger.db" self.db_path.parent.mkdir(parents=True, exist_ok=True) self._local = threading.local() self._init_schema() # Криптография узла self._private_key: Optional[str] = None self._public_key: Optional[str] = None self._node_keys: Dict[str, str] = {} # node_name -> public_key # Последний хэш для цепочки self._last_hash = self._get_last_hash() # HTTP сессия для broadcast self._http_session: Optional[aiohttp.ClientSession] = None # T2 счётчик self.t2_index = 0 logger.info(f"📒 TimeLedger инициализирован: {self.node_name}") logger.info(f" DB: {self.db_path}") logger.info(f" ML-DSA-65: {'✅' if ML_DSA_AVAILABLE else '❌'}") @contextmanager def _get_conn(self): """Thread-safe соединение""" if not hasattr(self._local, 'conn'): self._local.conn = sqlite3.connect( str(self.db_path), check_same_thread=False ) self._local.conn.row_factory = sqlite3.Row yield self._local.conn def _init_schema(self): """Создаёт таблицы""" with self._get_conn() as conn: conn.executescript(""" -- Транзакции (append-only ledger) CREATE TABLE IF NOT EXISTS transactions ( tx_id TEXT PRIMARY KEY, timestamp INTEGER NOT NULL, address TEXT NOT NULL, amount INTEGER NOT NULL, tx_type TEXT NOT NULL, node TEXT NOT NULL, t2_index INTEGER NOT NULL, prev_hash TEXT NOT NULL, signature TEXT, tx_hash TEXT NOT NULL, received_at INTEGER NOT NULL, verified INTEGER DEFAULT 0 ); -- Индексы для быстрого поиска CREATE INDEX IF NOT EXISTS idx_tx_address ON transactions(address); CREATE INDEX IF NOT EXISTS idx_tx_timestamp ON transactions(timestamp); CREATE INDEX IF NOT EXISTS idx_tx_node ON transactions(node); CREATE INDEX IF NOT EXISTS idx_tx_t2 ON transactions(t2_index); -- Кэш балансов (для скорости) CREATE TABLE IF NOT EXISTS balance_cache ( address TEXT PRIMARY KEY, balance INTEGER NOT NULL, last_tx_id TEXT, updated_at INTEGER NOT NULL ); -- Публичные ключи узлов CREATE TABLE IF NOT EXISTS node_keys ( node_name TEXT PRIMARY KEY, public_key TEXT NOT NULL, updated_at INTEGER NOT NULL ); -- Sync статус с другими узлами CREATE TABLE IF NOT EXISTS sync_status ( node_name TEXT PRIMARY KEY, last_tx_timestamp INTEGER DEFAULT 0, last_sync_at INTEGER DEFAULT 0, status TEXT DEFAULT 'unknown' ); """) conn.commit() def _get_last_hash(self) -> str: """Получает хэш последней транзакции""" with self._get_conn() as conn: cursor = conn.execute( "SELECT tx_hash FROM transactions ORDER BY timestamp DESC, tx_id DESC LIMIT 1" ) row = cursor.fetchone() return row["tx_hash"] if row else "0" * 64 # ═══════════════════════════════════════════════════════════════════════════ # КЛЮЧИ # ═══════════════════════════════════════════════════════════════════════════ def set_node_keys(self, private_key: str, public_key: str): """Устанавливает ключи текущего узла""" self._private_key = private_key self._public_key = public_key logger.info(f"🔑 Node keys set (ML-DSA-65)") def register_node_key(self, node_name: str, public_key: str): """Регистрирует публичный ключ другого узла""" self._node_keys[node_name] = public_key with self._get_conn() as conn: conn.execute(""" INSERT OR REPLACE INTO node_keys (node_name, public_key, updated_at) VALUES (?, ?, ?) """, (node_name, public_key, int(time.time() * 1000))) conn.commit() # ═══════════════════════════════════════════════════════════════════════════ # ТРАНЗАКЦИИ # ═══════════════════════════════════════════════════════════════════════════ def credit(self, address: str, amount: int, t2_index: int = None) -> Transaction: """ Начисляет монеты на адрес. Создаёт TX, сохраняет локально, транслирует на другие узлы. """ if amount <= 0: raise ValueError("Amount must be positive") tx = Transaction.create( address=address, amount=amount, tx_type="credit", node=self.node_name, t2_index=t2_index or self.t2_index, prev_hash=self._last_hash, private_key=self._private_key ) self._save_tx(tx) self._update_balance_cache(address) # Async broadcast (fire-and-forget) asyncio.create_task(self._broadcast_tx(tx)) logger.info(f"💰 Credit: {address} +{amount} Ɉ (TX: {tx.tx_id[:8]}...)") return tx def debit(self, address: str, amount: int) -> Optional[Transaction]: """ Списывает монеты с адреса. Проверяет баланс перед списанием. """ if amount <= 0: raise ValueError("Amount must be positive") balance = self.balance(address) if balance < amount: logger.warning(f"❌ Insufficient balance: {address} has {balance}, needs {amount}") return None tx = Transaction.create( address=address, amount=-amount, # Отрицательная сумма = debit tx_type="debit", node=self.node_name, t2_index=self.t2_index, prev_hash=self._last_hash, private_key=self._private_key ) self._save_tx(tx) self._update_balance_cache(address) asyncio.create_task(self._broadcast_tx(tx)) logger.info(f"💸 Debit: {address} -{amount} Ɉ (TX: {tx.tx_id[:8]}...)") return tx def transfer(self, from_addr: str, to_addr: str, amount: int) -> Optional[tuple]: """ Перевод между адресами. Создаёт две связанные TX (out + in). """ if amount <= 0: raise ValueError("Amount must be positive") balance = self.balance(from_addr) if balance < amount: return None # TX out (debit) tx_out = Transaction.create( address=from_addr, amount=-amount, tx_type="transfer_out", node=self.node_name, t2_index=self.t2_index, prev_hash=self._last_hash, private_key=self._private_key ) self._save_tx(tx_out) # TX in (credit) tx_in = Transaction.create( address=to_addr, amount=amount, tx_type="transfer_in", node=self.node_name, t2_index=self.t2_index, prev_hash=tx_out.tx_hash(), private_key=self._private_key ) self._save_tx(tx_in) self._update_balance_cache(from_addr) self._update_balance_cache(to_addr) # Broadcast both asyncio.create_task(self._broadcast_tx(tx_out)) asyncio.create_task(self._broadcast_tx(tx_in)) logger.info(f"💸 Transfer: {from_addr} → {to_addr}: {amount} Ɉ") return (tx_out, tx_in) def _save_tx(self, tx: Transaction): """Сохраняет транзакцию в локальную базу""" tx_hash = tx.tx_hash() with self._get_conn() as conn: try: conn.execute(""" INSERT INTO transactions (tx_id, timestamp, address, amount, tx_type, node, t2_index, prev_hash, signature, tx_hash, received_at, verified) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( tx.tx_id, tx.timestamp, tx.address, tx.amount, tx.tx_type, tx.node, tx.t2_index, tx.prev_hash, tx.signature, tx_hash, int(time.time() * 1000), 1 if tx.node == self.node_name else 0 )) conn.commit() self._last_hash = tx_hash except sqlite3.IntegrityError: # TX already exists — OK (идемпотентность) pass def receive_tx(self, tx_data: dict) -> bool: """ Получает транзакцию от другого узла. Верифицирует и сохраняет. """ try: tx = Transaction.from_dict(tx_data) # Верификация подписи if tx.node in self._node_keys: if not tx.verify(self._node_keys[tx.node]): logger.warning(f"❌ Invalid signature from {tx.node}: {tx.tx_id}") return False self._save_tx(tx) self._update_balance_cache(tx.address) logger.debug(f"📥 Received TX from {tx.node}: {tx.tx_id[:8]}...") return True except Exception as e: logger.error(f"❌ Error receiving TX: {e}") return False # ═══════════════════════════════════════════════════════════════════════════ # БАЛАНС # ═══════════════════════════════════════════════════════════════════════════ def balance(self, address: str) -> int: """ Возвращает баланс адреса. Баланс = SUM(amount) всех транзакций. """ with self._get_conn() as conn: cursor = conn.execute( "SELECT COALESCE(SUM(amount), 0) as balance FROM transactions WHERE address = ?", (address,) ) row = cursor.fetchone() return row["balance"] if row else 0 def balance_cached(self, address: str) -> int: """Баланс из кэша (быстрее)""" with self._get_conn() as conn: cursor = conn.execute( "SELECT balance FROM balance_cache WHERE address = ?", (address,) ) row = cursor.fetchone() if row: return row["balance"] # Кэш пустой — вычисляем и кэшируем return self._update_balance_cache(address) def _update_balance_cache(self, address: str) -> int: """Обновляет кэш баланса""" balance = self.balance(address) with self._get_conn() as conn: conn.execute(""" INSERT OR REPLACE INTO balance_cache (address, balance, updated_at) VALUES (?, ?, ?) """, (address, balance, int(time.time() * 1000))) conn.commit() return balance def get_balance_with_pending(self, address: str) -> Dict[str, int]: """Совместимость со старым API""" confirmed = self.balance(address) return { "confirmed": confirmed, "pending": 0, "total": confirmed } # ═══════════════════════════════════════════════════════════════════════════ # ИСТОРИЯ # ═══════════════════════════════════════════════════════════════════════════ def history(self, address: str, limit: int = 50) -> List[Dict]: """История транзакций адреса""" with self._get_conn() as conn: cursor = conn.execute(""" SELECT tx_id, timestamp, amount, tx_type, node, t2_index FROM transactions WHERE address = ? ORDER BY timestamp DESC LIMIT ? """, (address, limit)) return [dict(row) for row in cursor.fetchall()] def all_transactions(self, since_timestamp: int = 0, limit: int = 1000) -> List[Dict]: """Все транзакции (для синхронизации)""" with self._get_conn() as conn: cursor = conn.execute(""" SELECT * FROM transactions WHERE timestamp > ? ORDER BY timestamp ASC LIMIT ? """, (since_timestamp, limit)) return [dict(row) for row in cursor.fetchall()] def tx_count(self) -> int: """Количество транзакций в леджере""" with self._get_conn() as conn: cursor = conn.execute("SELECT COUNT(*) as c FROM transactions") return cursor.fetchone()["c"] # ═══════════════════════════════════════════════════════════════════════════ # BROADCAST (трансляция на другие узлы) # ═══════════════════════════════════════════════════════════════════════════ async def _get_session(self) -> aiohttp.ClientSession: """Получает или создаёт HTTP сессию""" if self._http_session is None or self._http_session.closed: timeout = aiohttp.ClientTimeout(total=5) self._http_session = aiohttp.ClientSession(timeout=timeout) return self._http_session async def _broadcast_tx(self, tx: Transaction): """Транслирует транзакцию на все узлы""" session = await self._get_session() tx_data = tx.to_dict() tasks = [] for node_name, node_info in NODES.items(): if node_name == self.node_name: continue # Не отправляем себе url = f"http://{node_info['ip']}:{node_info['port']}/tx" tasks.append(self._send_tx(session, url, node_name, tx_data)) if tasks: results = await asyncio.gather(*tasks, return_exceptions=True) success = sum(1 for r in results if r is True) logger.debug(f"📡 Broadcast TX {tx.tx_id[:8]}: {success}/{len(tasks)} nodes") async def _send_tx(self, session: aiohttp.ClientSession, url: str, node_name: str, tx_data: dict) -> bool: """Отправляет TX на один узел""" try: async with session.post(url, json=tx_data) as resp: if resp.status == 200: return True else: logger.debug(f"⚠️ {node_name}: HTTP {resp.status}") return False except Exception as e: logger.debug(f"⚠️ {node_name}: {type(e).__name__}") return False # ═══════════════════════════════════════════════════════════════════════════ # SYNC (синхронизация с другими узлами) # ═══════════════════════════════════════════════════════════════════════════ async def sync_from_node(self, node_name: str) -> int: """Синхронизирует транзакции с другого узла""" if node_name not in NODES: return 0 node_info = NODES[node_name] url = f"http://{node_info['ip']}:{node_info['port']}/sync" # Получаем timestamp последней TX от этого узла with self._get_conn() as conn: cursor = conn.execute( "SELECT last_tx_timestamp FROM sync_status WHERE node_name = ?", (node_name,) ) row = cursor.fetchone() since = row["last_tx_timestamp"] if row else 0 try: session = await self._get_session() async with session.get(url, params={"since": since}) as resp: if resp.status != 200: return 0 data = await resp.json() transactions = data.get("transactions", []) count = 0 max_ts = since for tx_data in transactions: if self.receive_tx(tx_data): count += 1 max_ts = max(max_ts, tx_data.get("timestamp", 0)) # Обновляем sync status with self._get_conn() as conn: conn.execute(""" INSERT OR REPLACE INTO sync_status (node_name, last_tx_timestamp, last_sync_at, status) VALUES (?, ?, ?, 'synced') """, (node_name, max_ts, int(time.time() * 1000))) conn.commit() if count > 0: logger.info(f"🔄 Synced {count} TX from {node_name}") return count except Exception as e: logger.debug(f"⚠️ Sync from {node_name} failed: {e}") return 0 async def sync_all(self) -> int: """Синхронизирует со всеми узлами""" total = 0 for node_name in NODES: if node_name != self.node_name: count = await self.sync_from_node(node_name) total += count return total # ═══════════════════════════════════════════════════════════════════════════ # СТАТИСТИКА # ═══════════════════════════════════════════════════════════════════════════ def stats(self) -> Dict[str, Any]: """Статистика леджера""" with self._get_conn() as conn: cursor = conn.execute("SELECT COUNT(*) as c FROM transactions") tx_count = cursor.fetchone()["c"] cursor = conn.execute("SELECT COUNT(DISTINCT address) as c FROM transactions") addr_count = cursor.fetchone()["c"] cursor = conn.execute("SELECT SUM(amount) as s FROM transactions WHERE amount > 0") total_minted = cursor.fetchone()["s"] or 0 cursor = conn.execute("SELECT MAX(timestamp) as m FROM transactions") last_tx = cursor.fetchone()["m"] or 0 return { "node": self.node_name, "transactions": tx_count, "addresses": addr_count, "total_minted": total_minted, "last_tx_timestamp": last_tx, "t2_index": self.t2_index, "ml_dsa_65": ML_DSA_AVAILABLE, "last_hash": self._last_hash[:16] + "..." } # ═══════════════════════════════════════════════════════════════════════════════ # HTTP API (для приёма транзакций от других узлов) # ═══════════════════════════════════════════════════════════════════════════════ from aiohttp import web class LedgerAPI: """HTTP API для TIME_LEDGER""" def __init__(self, ledger: TimeLedger, port: int = 8765): self.ledger = ledger self.port = port self.app = web.Application() self._setup_routes() def _setup_routes(self): self.app.router.add_post('/tx', self.receive_tx) self.app.router.add_get('/sync', self.sync_handler) self.app.router.add_get('/balance/{address}', self.balance_handler) self.app.router.add_get('/stats', self.stats_handler) self.app.router.add_get('/health', self.health_handler) async def receive_tx(self, request: web.Request) -> web.Response: """Получает транзакцию от другого узла""" try: tx_data = await request.json() success = self.ledger.receive_tx(tx_data) return web.json_response({"success": success}) except Exception as e: return web.json_response({"error": str(e)}, status=400) async def sync_handler(self, request: web.Request) -> web.Response: """Отдаёт транзакции для синхронизации""" since = int(request.query.get("since", 0)) transactions = self.ledger.all_transactions(since_timestamp=since) return web.json_response({"transactions": transactions}) async def balance_handler(self, request: web.Request) -> web.Response: """Возвращает баланс адреса""" address = request.match_info["address"] balance = self.ledger.balance(address) return web.json_response({"address": address, "balance": balance}) async def stats_handler(self, request: web.Request) -> web.Response: """Статистика леджера""" return web.json_response(self.ledger.stats()) async def health_handler(self, request: web.Request) -> web.Response: """Health check""" return web.json_response({"status": "ok", "node": self.ledger.node_name}) async def start(self): """Запускает HTTP сервер""" runner = web.AppRunner(self.app) await runner.setup() site = web.TCPSite(runner, '0.0.0.0', self.port) await site.start() logger.info(f"🌐 Ledger API started on port {self.port}") # ═══════════════════════════════════════════════════════════════════════════════ # SINGLETON # ═══════════════════════════════════════════════════════════════════════════════ _instance: Optional[TimeLedger] = None _lock = threading.Lock() def get_ledger() -> TimeLedger: """Возвращает глобальный экземпляр TimeLedger""" global _instance with _lock: if _instance is None: _instance = TimeLedger() return _instance # ═══════════════════════════════════════════════════════════════════════════════ # CLI # ═══════════════════════════════════════════════════════════════════════════════ if __name__ == "__main__": import sys ledger = get_ledger() if len(sys.argv) < 2: print(f""" TIME_LEDGER — Распределённый леджер Montana ═══════════════════════════════════════════ Узел: {ledger.node_name} TX в леджере: {ledger.tx_count()} Команды: balance
— баланс адреса credit