777 lines
27 KiB
Python
777 lines
27 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
EVENT LEDGER — Event Sourcing для Montana Protocol
|
||
====================================================
|
||
|
||
ИДЕАЛЬНОЕ РЕШЕНИЕ синхронизации:
|
||
- Все изменения балансов через неизменяемые события
|
||
- События реплицируются между узлами
|
||
- Баланс = сумма всех событий для адреса
|
||
- Конфликты невозможны (append-only log)
|
||
|
||
СОБЫТИЯ:
|
||
- EMISSION: начисление от TIME_BANK
|
||
- TRANSFER: перевод между пользователями
|
||
- ESCROW_LOCK: заморозка для контракта
|
||
- ESCROW_RELEASE: освобождение escrow
|
||
|
||
ИДЕНТИФИКАТОР СОБЫТИЯ:
|
||
{timestamp_ms}.{node_id}.{counter}
|
||
Гарантирует глобальную уникальность + сортировку
|
||
|
||
Автор: Montana Protocol
|
||
"""
|
||
|
||
import json
|
||
import time
|
||
import threading
|
||
import hashlib
|
||
import os
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
from typing import Dict, List, Optional, Any, Tuple
|
||
from dataclasses import dataclass, asdict
|
||
import logging
|
||
|
||
logging.basicConfig(level=logging.INFO)
|
||
logger = logging.getLogger("EVENT_LEDGER")
|
||
|
||
|
||
# ============================================================
|
||
# ТИПЫ СОБЫТИЙ
|
||
# ============================================================
|
||
|
||
class EventType:
|
||
"""Типы событий Montana"""
|
||
EMISSION = "EMISSION" # TIME_BANK начисление
|
||
TRANSFER = "TRANSFER" # Перевод между адресами
|
||
ESCROW_LOCK = "ESCROW_LOCK" # Заморозка для контракта
|
||
ESCROW_RELEASE = "ESCROW_RELEASE" # Освобождение escrow
|
||
GENESIS = "GENESIS" # Начальное событие
|
||
|
||
|
||
@dataclass
|
||
class Event:
|
||
"""
|
||
Неизменяемое событие Montana
|
||
|
||
Поля:
|
||
event_id: Уникальный ID (timestamp_ms.node_id.counter)
|
||
event_type: Тип события (EMISSION, TRANSFER, etc)
|
||
timestamp: Unix timestamp создания
|
||
from_addr: Адрес отправителя (или "TIME_BANK" для эмиссии)
|
||
to_addr: Адрес получателя
|
||
amount: Сумма в Ɉ
|
||
metadata: Дополнительные данные (JSON)
|
||
node_id: ID узла-создателя
|
||
prev_hash: Hash предыдущего события (цепочка)
|
||
event_hash: Hash этого события
|
||
"""
|
||
event_id: str
|
||
event_type: str
|
||
timestamp: float
|
||
from_addr: str
|
||
to_addr: str
|
||
amount: int
|
||
metadata: Dict[str, Any]
|
||
node_id: str
|
||
prev_hash: str
|
||
event_hash: str = ""
|
||
timestamp_ns: int = 0 # Наносекундная точность (time.time_ns())
|
||
|
||
def __post_init__(self):
|
||
# [FIX] Валидация timestamp_ns для защиты от future timestamps
|
||
if self.timestamp_ns > 0:
|
||
now_ns = time.time_ns()
|
||
# Genesis: 09.01.2026 00:00:00 UTC
|
||
genesis_ns = 1736380800_000_000_000
|
||
|
||
# Future check (allow 1 hour clock drift для P2P sync)
|
||
if self.timestamp_ns > now_ns + 3600_000_000_000:
|
||
logger.warning(f"Future timestamp detected: {self.timestamp_ns}, clamping to now")
|
||
self.timestamp_ns = now_ns
|
||
|
||
# Past check (before genesis)
|
||
if self.timestamp_ns < genesis_ns:
|
||
logger.warning(f"Timestamp before genesis: {self.timestamp_ns}, using timestamp fallback")
|
||
self.timestamp_ns = int(self.timestamp * 1e9) if self.timestamp > 0 else now_ns
|
||
|
||
if not self.event_hash:
|
||
self.event_hash = self._compute_hash()
|
||
|
||
def _compute_hash(self) -> str:
|
||
"""SHA-256 хеш события для верификации"""
|
||
data = f"{self.event_id}:{self.event_type}:{self.timestamp}:{self.from_addr}:{self.to_addr}:{self.amount}:{json.dumps(self.metadata, sort_keys=True)}:{self.node_id}:{self.prev_hash}"
|
||
return hashlib.sha256(data.encode()).hexdigest()
|
||
|
||
def verify(self) -> bool:
|
||
"""Проверяет целостность события"""
|
||
return self.event_hash == self._compute_hash()
|
||
|
||
def to_dict(self) -> Dict[str, Any]:
|
||
d = asdict(self)
|
||
|
||
# [FIX] Convert timestamp_ns=0 → timestamp*1e9 for old events
|
||
if d["timestamp_ns"] == 0 and self.timestamp > 0:
|
||
d["timestamp_ns"] = int(self.timestamp * 1e9)
|
||
|
||
# Add ISO 8601 with nanoseconds: 2026-02-12T15:30:45.123456789Z
|
||
timestamp_ns = d["timestamp_ns"]
|
||
if timestamp_ns > 0:
|
||
secs = timestamp_ns // 1_000_000_000
|
||
nanos = timestamp_ns % 1_000_000_000
|
||
dt = datetime.fromtimestamp(secs, tz=timezone.utc)
|
||
d["timestamp_iso"] = dt.strftime(f"%Y-%m-%dT%H:%M:%S.{nanos:09d}Z")
|
||
else:
|
||
# Fallback: microsecond precision from float timestamp
|
||
dt = datetime.fromtimestamp(self.timestamp, tz=timezone.utc)
|
||
d["timestamp_iso"] = dt.strftime("%Y-%m-%dT%H:%M:%S.") + f"{int((self.timestamp % 1) * 1e9):09d}Z"
|
||
return d
|
||
|
||
@classmethod
|
||
def from_dict(cls, data: Dict[str, Any]) -> 'Event':
|
||
# Filter out computed fields (timestamp_iso) that aren't dataclass fields
|
||
d = dict(data)
|
||
d.pop("timestamp_iso", None)
|
||
return cls(**d)
|
||
|
||
|
||
# ============================================================
|
||
# EVENT LEDGER
|
||
# ============================================================
|
||
|
||
class EventLedger:
|
||
"""
|
||
Event Sourcing для Montana Protocol
|
||
|
||
АРХИТЕКТУРА:
|
||
1. Все изменения балансов = события
|
||
2. События неизменяемы (append-only)
|
||
3. Баланс = replay всех событий
|
||
4. Синхронизация = репликация событий
|
||
|
||
ХРАНЕНИЕ:
|
||
- events.jsonl — append-only log событий
|
||
- balances.json — кэш балансов (rebuild из событий)
|
||
"""
|
||
|
||
GENESIS_HASH = "0" * 64
|
||
TIME_BANK_ADDR = "TIME_BANK"
|
||
ESCROW_PREFIX = "escrow:"
|
||
|
||
def __init__(self, data_dir: Optional[Path] = None):
|
||
self.data_dir = data_dir or Path(__file__).parent / "data"
|
||
self.data_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
self.events_file = self.data_dir / "events.jsonl"
|
||
self.balances_cache_file = self.data_dir / "balances_cache.json"
|
||
|
||
# Node ID для уникальности событий
|
||
self.node_id = self._get_node_id()
|
||
|
||
# Счётчик событий для уникальности в пределах ms
|
||
self._event_counter = 0
|
||
self._counter_lock = threading.Lock()
|
||
|
||
# Кэш балансов (пересчитывается из событий)
|
||
self._balances: Dict[str, int] = {}
|
||
self._balances_lock = threading.RLock()
|
||
|
||
# File write lock (prevents concurrent writes to events.jsonl)
|
||
self._write_lock = threading.Lock()
|
||
|
||
# In-memory event ID cache (avoids full file read on merge)
|
||
self._known_event_ids: set = set()
|
||
|
||
# Последний hash для цепочки
|
||
self._last_hash = self.GENESIS_HASH
|
||
|
||
# Загружаем существующие события
|
||
self._load_events()
|
||
|
||
logger.info(f"EVENT_LEDGER initialized: node={self.node_id}")
|
||
logger.info(f" Events: {self.events_file}")
|
||
logger.info(f" Total events: {self._event_counter}")
|
||
logger.info(f" Addresses: {len(self._balances)}")
|
||
|
||
def _get_node_id(self) -> str:
|
||
"""Получает уникальный ID узла"""
|
||
# Пробуем из environment
|
||
node_id = os.environ.get("MONTANA_NODE_ID")
|
||
if node_id:
|
||
return node_id
|
||
|
||
# Из файла
|
||
node_file = self.data_dir / "node_id.txt"
|
||
if node_file.exists():
|
||
return node_file.read_text().strip()
|
||
|
||
# Генерируем новый
|
||
import socket
|
||
hostname = socket.gethostname()
|
||
node_id = hashlib.sha256(f"{hostname}:{time.time()}".encode()).hexdigest()[:8]
|
||
|
||
node_file.write_text(node_id)
|
||
return node_id
|
||
|
||
def _generate_event_id(self) -> str:
|
||
"""
|
||
Генерирует глобально уникальный ID события.
|
||
|
||
Формат: {timestamp_ms}.{node_id}.{counter}
|
||
|
||
Гарантии:
|
||
- Уникальность: node_id + counter
|
||
- Сортировка: timestamp первый
|
||
- Читаемость: человекочитаемый формат
|
||
"""
|
||
with self._counter_lock:
|
||
timestamp_ns = time.time_ns()
|
||
self._event_counter += 1
|
||
return f"{timestamp_ns}.{self.node_id}.{self._event_counter}"
|
||
|
||
# --------------------------------------------------------
|
||
# PERSISTENCE
|
||
# --------------------------------------------------------
|
||
|
||
def _load_events(self):
|
||
"""Загружает события из файла и пересчитывает балансы"""
|
||
if not self.events_file.exists():
|
||
logger.info("No events file, starting fresh")
|
||
return
|
||
|
||
events_loaded = 0
|
||
with open(self.events_file, 'r', encoding='utf-8') as f:
|
||
for line in f:
|
||
line = line.strip()
|
||
if not line:
|
||
continue
|
||
try:
|
||
data = json.loads(line)
|
||
event = Event.from_dict(data)
|
||
|
||
# Верифицируем событие
|
||
if not event.verify():
|
||
logger.error(f"Invalid event hash: {event.event_id}")
|
||
continue
|
||
|
||
# Применяем к балансам
|
||
self._apply_event_to_balances(event)
|
||
|
||
# Обновляем last_hash
|
||
self._last_hash = event.event_hash
|
||
|
||
# Обновляем counter
|
||
parts = event.event_id.split('.')
|
||
if len(parts) >= 3 and parts[1] == self.node_id:
|
||
counter = int(parts[2])
|
||
if counter > self._event_counter:
|
||
self._event_counter = counter
|
||
|
||
self._known_event_ids.add(event.event_id)
|
||
events_loaded += 1
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error loading event: {e}")
|
||
|
||
logger.info(f"Loaded {events_loaded} events, {len(self._known_event_ids)} IDs cached")
|
||
|
||
def _append_event(self, event: Event):
|
||
"""Записывает событие в файл (append-only, thread-safe)"""
|
||
with self._write_lock:
|
||
with open(self.events_file, 'a', encoding='utf-8') as f:
|
||
f.write(json.dumps(event.to_dict(), ensure_ascii=False) + '\n')
|
||
self._known_event_ids.add(event.event_id)
|
||
|
||
def _apply_event_to_balances(self, event: Event):
|
||
"""Применяет событие к кэшу балансов"""
|
||
with self._balances_lock:
|
||
# Инициализируем балансы если нужно
|
||
if event.from_addr not in self._balances:
|
||
self._balances[event.from_addr] = 0
|
||
if event.to_addr not in self._balances:
|
||
self._balances[event.to_addr] = 0
|
||
|
||
# Применяем изменение
|
||
if event.from_addr != self.TIME_BANK_ADDR:
|
||
self._balances[event.from_addr] -= event.amount
|
||
self._balances[event.to_addr] += event.amount
|
||
|
||
def _save_balances_cache(self):
|
||
"""Сохраняет кэш балансов для быстрой загрузки"""
|
||
with self._balances_lock:
|
||
with open(self.balances_cache_file, 'w', encoding='utf-8') as f:
|
||
json.dump({
|
||
"last_hash": self._last_hash,
|
||
"balances": self._balances,
|
||
"updated_at": datetime.now(timezone.utc).isoformat()
|
||
}, f, ensure_ascii=False, indent=2)
|
||
|
||
# --------------------------------------------------------
|
||
# СОЗДАНИЕ СОБЫТИЙ
|
||
# --------------------------------------------------------
|
||
|
||
def emit(
|
||
self,
|
||
to_addr: str,
|
||
amount: int,
|
||
metadata: Optional[Dict] = None
|
||
) -> Event:
|
||
"""
|
||
Создаёт событие EMISSION (начисление от TIME_BANK).
|
||
|
||
Args:
|
||
to_addr: Адрес получателя
|
||
amount: Сумма в Ɉ
|
||
metadata: Дополнительные данные
|
||
|
||
Returns:
|
||
Созданное событие
|
||
"""
|
||
event = Event(
|
||
event_id=self._generate_event_id(),
|
||
event_type=EventType.EMISSION,
|
||
timestamp=time.time(),
|
||
from_addr=self.TIME_BANK_ADDR,
|
||
to_addr=str(to_addr),
|
||
amount=amount,
|
||
metadata=metadata or {},
|
||
node_id=self.node_id,
|
||
prev_hash=self._last_hash,
|
||
timestamp_ns=time.time_ns()
|
||
)
|
||
|
||
# Сохраняем и применяем
|
||
self._append_event(event)
|
||
self._apply_event_to_balances(event)
|
||
self._last_hash = event.event_hash
|
||
|
||
logger.info(f"EMIT: {amount} Ɉ → {to_addr} [{event.event_id}]")
|
||
return event
|
||
|
||
def transfer(
|
||
self,
|
||
from_addr: str,
|
||
to_addr: str,
|
||
amount: int,
|
||
metadata: Optional[Dict] = None,
|
||
skip_balance_check: bool = False
|
||
) -> Tuple[bool, str, Optional[Event]]:
|
||
"""
|
||
Создаёт событие TRANSFER (перевод).
|
||
|
||
Args:
|
||
from_addr: Адрес отправителя
|
||
to_addr: Адрес получателя
|
||
amount: Сумма в Ɉ
|
||
metadata: Дополнительные данные
|
||
skip_balance_check: Пропустить проверку баланса (для уже подтверждённых переводов)
|
||
|
||
Returns:
|
||
(success, message, event)
|
||
"""
|
||
from_addr = str(from_addr)
|
||
to_addr = str(to_addr)
|
||
|
||
# Проверяем баланс (если не пропущена проверка)
|
||
if not skip_balance_check:
|
||
with self._balances_lock:
|
||
balance = self._balances.get(from_addr, 0)
|
||
if balance < amount:
|
||
return False, f"Недостаточно средств: {balance} < {amount}", None
|
||
|
||
event = Event(
|
||
event_id=self._generate_event_id(),
|
||
event_type=EventType.TRANSFER,
|
||
timestamp=time.time(),
|
||
from_addr=from_addr,
|
||
to_addr=to_addr,
|
||
amount=amount,
|
||
metadata=metadata or {},
|
||
node_id=self.node_id,
|
||
prev_hash=self._last_hash,
|
||
timestamp_ns=time.time_ns()
|
||
)
|
||
|
||
# Сохраняем и применяем
|
||
self._append_event(event)
|
||
self._apply_event_to_balances(event)
|
||
self._last_hash = event.event_hash
|
||
|
||
logger.info(f"TRANSFER: {from_addr} → {to_addr}, {amount} Ɉ [{event.event_id}]")
|
||
return True, "OK", event
|
||
|
||
def escrow_lock(
|
||
self,
|
||
from_addr: str,
|
||
contract_id: str,
|
||
amount: int,
|
||
metadata: Optional[Dict] = None
|
||
) -> Tuple[bool, str, Optional[Event]]:
|
||
"""
|
||
Создаёт событие ESCROW_LOCK (заморозка для контракта).
|
||
|
||
Args:
|
||
from_addr: Адрес создателя контракта
|
||
contract_id: ID контракта
|
||
amount: Сумма в Ɉ
|
||
metadata: Дополнительные данные
|
||
|
||
Returns:
|
||
(success, message, event)
|
||
"""
|
||
from_addr = str(from_addr)
|
||
escrow_addr = f"{self.ESCROW_PREFIX}{contract_id}"
|
||
|
||
# Проверяем баланс
|
||
with self._balances_lock:
|
||
balance = self._balances.get(from_addr, 0)
|
||
if balance < amount:
|
||
return False, f"Недостаточно средств: {balance} < {amount}", None
|
||
|
||
event = Event(
|
||
event_id=self._generate_event_id(),
|
||
event_type=EventType.ESCROW_LOCK,
|
||
timestamp=time.time(),
|
||
from_addr=from_addr,
|
||
to_addr=escrow_addr,
|
||
amount=amount,
|
||
metadata={**(metadata or {}), "contract_id": contract_id},
|
||
node_id=self.node_id,
|
||
prev_hash=self._last_hash,
|
||
timestamp_ns=time.time_ns()
|
||
)
|
||
|
||
self._append_event(event)
|
||
self._apply_event_to_balances(event)
|
||
self._last_hash = event.event_hash
|
||
|
||
logger.info(f"ESCROW_LOCK: {from_addr} → {escrow_addr}, {amount} Ɉ")
|
||
return True, "OK", event
|
||
|
||
def escrow_release(
|
||
self,
|
||
contract_id: str,
|
||
to_addr: str,
|
||
metadata: Optional[Dict] = None
|
||
) -> Tuple[bool, str, Optional[Event]]:
|
||
"""
|
||
Создаёт событие ESCROW_RELEASE (освобождение escrow).
|
||
|
||
Args:
|
||
contract_id: ID контракта
|
||
to_addr: Адрес получателя (победитель или создатель при отмене)
|
||
metadata: Дополнительные данные
|
||
|
||
Returns:
|
||
(success, message, event)
|
||
"""
|
||
escrow_addr = f"{self.ESCROW_PREFIX}{contract_id}"
|
||
to_addr = str(to_addr)
|
||
|
||
# Проверяем баланс escrow
|
||
with self._balances_lock:
|
||
amount = self._balances.get(escrow_addr, 0)
|
||
if amount <= 0:
|
||
return False, "Escrow пуст", None
|
||
|
||
event = Event(
|
||
event_id=self._generate_event_id(),
|
||
event_type=EventType.ESCROW_RELEASE,
|
||
timestamp=time.time(),
|
||
from_addr=escrow_addr,
|
||
to_addr=to_addr,
|
||
amount=amount,
|
||
metadata={**(metadata or {}), "contract_id": contract_id},
|
||
node_id=self.node_id,
|
||
prev_hash=self._last_hash,
|
||
timestamp_ns=time.time_ns()
|
||
)
|
||
|
||
self._append_event(event)
|
||
self._apply_event_to_balances(event)
|
||
self._last_hash = event.event_hash
|
||
|
||
logger.info(f"ESCROW_RELEASE: {escrow_addr} → {to_addr}, {amount} Ɉ")
|
||
return True, "OK", event
|
||
|
||
# --------------------------------------------------------
|
||
# QUERY API
|
||
# --------------------------------------------------------
|
||
|
||
def balance(self, address: str) -> int:
|
||
"""Возвращает баланс адреса"""
|
||
with self._balances_lock:
|
||
return self._balances.get(str(address), 0)
|
||
|
||
def balances(self) -> Dict[str, int]:
|
||
"""Возвращает все балансы"""
|
||
with self._balances_lock:
|
||
return dict(self._balances)
|
||
|
||
def get_events(
|
||
self,
|
||
address: Optional[str] = None,
|
||
event_type: Optional[str] = None,
|
||
limit: int = 100
|
||
) -> List[Event]:
|
||
"""
|
||
Возвращает события с фильтрацией.
|
||
|
||
Args:
|
||
address: Фильтр по адресу (from или to)
|
||
event_type: Фильтр по типу события
|
||
limit: Максимальное количество
|
||
|
||
Returns:
|
||
Список событий (newest first, sorted by timestamp_ns)
|
||
"""
|
||
# [FIX] DoS Protection: Max limit 10000 events
|
||
limit = min(limit, 10000)
|
||
|
||
events = []
|
||
|
||
if not self.events_file.exists():
|
||
return events
|
||
|
||
# Читаем ВСЕ события (до сортировки)
|
||
with open(self.events_file, 'r', encoding='utf-8') as f:
|
||
lines = f.readlines()
|
||
|
||
for line in lines:
|
||
line = line.strip()
|
||
if not line:
|
||
continue
|
||
|
||
try:
|
||
data = json.loads(line)
|
||
event = Event.from_dict(data)
|
||
|
||
# Фильтр по адресу
|
||
if address:
|
||
addr = str(address)
|
||
if event.from_addr != addr and event.to_addr != addr:
|
||
continue
|
||
|
||
# Фильтр по типу
|
||
if event_type and event.event_type != event_type:
|
||
continue
|
||
|
||
events.append(event)
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error parsing event: {e}")
|
||
|
||
# КРИТИЧНО: Сортировка по timestamp_ns (наносекунды) — newest first
|
||
events.sort(key=lambda e: e.timestamp_ns if e.timestamp_ns > 0 else int(e.timestamp * 1e9), reverse=True)
|
||
|
||
# Лимит после сортировки
|
||
return events[:limit]
|
||
|
||
def get_events_since(self, last_event_id: str) -> List[Event]:
|
||
"""
|
||
Возвращает события после указанного ID.
|
||
|
||
Используется для синхронизации между узлами.
|
||
|
||
Args:
|
||
last_event_id: ID последнего известного события
|
||
|
||
Returns:
|
||
Список новых событий
|
||
"""
|
||
events = []
|
||
found = False if last_event_id else True
|
||
|
||
if not self.events_file.exists():
|
||
return events
|
||
|
||
with open(self.events_file, 'r', encoding='utf-8') as f:
|
||
for line in f:
|
||
line = line.strip()
|
||
if not line:
|
||
continue
|
||
|
||
try:
|
||
data = json.loads(line)
|
||
event = Event.from_dict(data)
|
||
|
||
if found:
|
||
events.append(event)
|
||
elif event.event_id == last_event_id:
|
||
found = True
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error parsing event: {e}")
|
||
|
||
return events
|
||
|
||
def merge_events(self, remote_events: List[Dict[str, Any]]) -> int:
|
||
"""
|
||
Мержит события от другого узла.
|
||
|
||
Дедупликация по event_id (in-memory cache, O(1) lookup).
|
||
|
||
Args:
|
||
remote_events: Список событий от удалённого узла
|
||
|
||
Returns:
|
||
Количество добавленных событий
|
||
"""
|
||
added = 0
|
||
for event_data in remote_events:
|
||
event_id = event_data.get("event_id")
|
||
|
||
# Пропускаем дубликаты (in-memory cache — без чтения файла)
|
||
if event_id in self._known_event_ids:
|
||
continue
|
||
|
||
try:
|
||
event = Event.from_dict(event_data)
|
||
|
||
# Верифицируем
|
||
if not event.verify():
|
||
logger.warning(f"Invalid remote event: {event_id}")
|
||
continue
|
||
|
||
# Добавляем (thread-safe: _append_event handles write lock + cache)
|
||
self._append_event(event)
|
||
self._apply_event_to_balances(event)
|
||
added += 1
|
||
|
||
logger.info(f"MERGED: {event.event_type} {event_id}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error merging event {event_id}: {e}")
|
||
|
||
if added > 0:
|
||
self._save_balances_cache()
|
||
logger.info(f"Merged {added} events from remote")
|
||
|
||
return added
|
||
|
||
# --------------------------------------------------------
|
||
# STATS
|
||
# --------------------------------------------------------
|
||
|
||
def stats(self) -> Dict[str, Any]:
|
||
"""Статистика ledger'а"""
|
||
with self._balances_lock:
|
||
total_supply = sum(
|
||
b for addr, b in self._balances.items()
|
||
if not addr.startswith(self.ESCROW_PREFIX)
|
||
and addr != self.TIME_BANK_ADDR
|
||
)
|
||
|
||
locked_in_escrow = sum(
|
||
b for addr, b in self._balances.items()
|
||
if addr.startswith(self.ESCROW_PREFIX)
|
||
)
|
||
|
||
return {
|
||
"node_id": self.node_id,
|
||
"event_counter": self._event_counter,
|
||
"last_hash": self._last_hash[:16] + "...",
|
||
"addresses": len(self._balances),
|
||
"total_supply": total_supply,
|
||
"locked_in_escrow": locked_in_escrow,
|
||
"events_file": str(self.events_file)
|
||
}
|
||
|
||
|
||
# ============================================================
|
||
# SINGLETON
|
||
# ============================================================
|
||
|
||
_instance: Optional[EventLedger] = None
|
||
_lock = threading.Lock()
|
||
|
||
def get_event_ledger() -> EventLedger:
|
||
"""Возвращает глобальный экземпляр EventLedger"""
|
||
global _instance
|
||
with _lock:
|
||
if _instance is None:
|
||
_instance = EventLedger()
|
||
return _instance
|
||
|
||
|
||
# ============================================================
|
||
# CLI
|
||
# ============================================================
|
||
|
||
if __name__ == "__main__":
|
||
import sys
|
||
|
||
ledger = get_event_ledger()
|
||
|
||
if len(sys.argv) < 2:
|
||
print("""
|
||
EVENT LEDGER — Event Sourcing для Montana Protocol
|
||
===================================================
|
||
|
||
Команды:
|
||
stats — статистика
|
||
balance <addr> — баланс адреса
|
||
emit <addr> <amount> — начислить (эмиссия)
|
||
transfer <from> <to> <amount> — перевод
|
||
events [addr] [limit] — история событий
|
||
export — экспорт событий в JSON
|
||
""")
|
||
sys.exit(0)
|
||
|
||
cmd = sys.argv[1]
|
||
|
||
if cmd == "stats":
|
||
s = ledger.stats()
|
||
print("EVENT LEDGER Stats:")
|
||
print("=" * 50)
|
||
for k, v in s.items():
|
||
print(f" {k}: {v}")
|
||
|
||
elif cmd == "balance" and len(sys.argv) > 2:
|
||
addr = sys.argv[2]
|
||
print(f"Balance {addr}: {ledger.balance(addr)} Ɉ")
|
||
|
||
elif cmd == "emit" and len(sys.argv) > 3:
|
||
addr = sys.argv[2]
|
||
amount = int(sys.argv[3])
|
||
event = ledger.emit(addr, amount)
|
||
print(f"EMISSION: {amount} Ɉ → {addr}")
|
||
print(f"Event ID: {event.event_id}")
|
||
|
||
elif cmd == "transfer" and len(sys.argv) > 4:
|
||
from_addr = sys.argv[2]
|
||
to_addr = sys.argv[3]
|
||
amount = int(sys.argv[4])
|
||
ok, msg, event = ledger.transfer(from_addr, to_addr, amount)
|
||
if ok:
|
||
print(f"TRANSFER: {from_addr} → {to_addr}, {amount} Ɉ")
|
||
print(f"Event ID: {event.event_id}")
|
||
else:
|
||
print(f"ERROR: {msg}")
|
||
|
||
elif cmd == "events":
|
||
addr = sys.argv[2] if len(sys.argv) > 2 else None
|
||
limit = int(sys.argv[3]) if len(sys.argv) > 3 else 20
|
||
events = ledger.get_events(address=addr, limit=limit)
|
||
print(f"Events ({len(events)}):")
|
||
print("-" * 60)
|
||
for e in events:
|
||
ts = datetime.fromtimestamp(e.timestamp).strftime("%Y-%m-%d %H:%M:%S")
|
||
print(f"{ts} {e.event_type}: {e.from_addr} → {e.to_addr}, {e.amount} Ɉ")
|
||
|
||
elif cmd == "export":
|
||
events = ledger.get_events(limit=10000)
|
||
output = {
|
||
"exported_at": datetime.now(timezone.utc).isoformat(),
|
||
"node_id": ledger.node_id,
|
||
"events": [e.to_dict() for e in reversed(events)]
|
||
}
|
||
output_file = ledger.data_dir / "events_export.json"
|
||
with open(output_file, 'w', encoding='utf-8') as f:
|
||
json.dump(output, f, ensure_ascii=False, indent=2)
|
||
print(f"Exported to {output_file}")
|
||
|
||
else:
|
||
print(f"Unknown command: {cmd}")
|