792 lines
33 KiB
Python
792 lines
33 KiB
Python
|
|
#!/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 <address> — баланс адреса
|
|||
|
|
credit <addr> <amt> — начислить
|
|||
|
|
transfer <from> <to> <amt> — перевод
|
|||
|
|
history <address> — история TX
|
|||
|
|
stats — статистика
|
|||
|
|
serve — запустить API сервер
|
|||
|
|
""")
|
|||
|
|
sys.exit(0)
|
|||
|
|
|
|||
|
|
cmd = sys.argv[1]
|
|||
|
|
|
|||
|
|
if cmd == "balance" and len(sys.argv) > 2:
|
|||
|
|
addr = sys.argv[2]
|
|||
|
|
print(f"💰 {addr}: {ledger.balance(addr)} Ɉ")
|
|||
|
|
|
|||
|
|
elif cmd == "credit" and len(sys.argv) > 3:
|
|||
|
|
addr = sys.argv[2]
|
|||
|
|
amount = int(sys.argv[3])
|
|||
|
|
tx = ledger.credit(addr, amount)
|
|||
|
|
print(f"✓ Credit TX: {tx.tx_id}")
|
|||
|
|
|
|||
|
|
elif cmd == "transfer" and len(sys.argv) > 4:
|
|||
|
|
from_addr = sys.argv[2]
|
|||
|
|
to_addr = sys.argv[3]
|
|||
|
|
amount = int(sys.argv[4])
|
|||
|
|
result = ledger.transfer(from_addr, to_addr, amount)
|
|||
|
|
if result:
|
|||
|
|
print(f"✓ Transfer: {result[0].tx_id[:8]}... → {result[1].tx_id[:8]}...")
|
|||
|
|
else:
|
|||
|
|
print("❌ Insufficient balance")
|
|||
|
|
|
|||
|
|
elif cmd == "history" and len(sys.argv) > 2:
|
|||
|
|
addr = sys.argv[2]
|
|||
|
|
for tx in ledger.history(addr, limit=10):
|
|||
|
|
sign = "+" if tx["amount"] > 0 else ""
|
|||
|
|
print(f" {tx['timestamp']} | {sign}{tx['amount']} | {tx['tx_type']} | {tx['node']}")
|
|||
|
|
|
|||
|
|
elif cmd == "stats":
|
|||
|
|
for k, v in ledger.stats().items():
|
|||
|
|
print(f"{k}: {v}")
|
|||
|
|
|
|||
|
|
elif cmd == "serve":
|
|||
|
|
async def main():
|
|||
|
|
api = LedgerAPI(ledger)
|
|||
|
|
await api.start()
|
|||
|
|
# Keep running
|
|||
|
|
while True:
|
|||
|
|
await asyncio.sleep(3600)
|
|||
|
|
|
|||
|
|
asyncio.run(main())
|
|||
|
|
|
|||
|
|
else:
|
|||
|
|
print(f"Unknown command: {cmd}")
|