1170 lines
48 KiB
Python
1170 lines
48 KiB
Python
|
|
#!/usr/bin/env python3
|
|||
|
|
"""
|
|||
|
|
COUNCIL VOTING — Голосование Совета Montana Guardian
|
|||
|
|
=====================================================
|
|||
|
|
|
|||
|
|
MAINNET PRODUCTION — НЕ ЗАГЛУШКА
|
|||
|
|
|
|||
|
|
Механика:
|
|||
|
|
1. Любой узел создаёт предложение (Proposal)
|
|||
|
|
2. Все 5 узлов голосуют ЗА или ПРОТИВ (24 часа)
|
|||
|
|
3. Единогласие (100%) = принято
|
|||
|
|
4. Любой ПРОТИВ = отклонено
|
|||
|
|
5. Подписи ML-DSA-65 криптографически верифицируются
|
|||
|
|
|
|||
|
|
УЗЛЫ СОВЕТА (5 полных узлов):
|
|||
|
|
- amsterdam (72.56.102.240) — PRIMARY
|
|||
|
|
- moscow (176.124.208.93)
|
|||
|
|
- almaty (91.200.148.93)
|
|||
|
|
- spb (188.225.58.98)
|
|||
|
|
- novosibirsk (147.45.147.247)
|
|||
|
|
|
|||
|
|
Наблюдатель (человек) имеет право вето.
|
|||
|
|
|
|||
|
|
ИНТЕГРАЦИЯ:
|
|||
|
|
- Бот: /council, /propose, /vote
|
|||
|
|
- MiniApp: council_status(), cast_vote()
|
|||
|
|
- API: /api/council/status, /api/council/vote
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import hashlib
|
|||
|
|
import json
|
|||
|
|
import sqlite3
|
|||
|
|
import logging
|
|||
|
|
from pathlib import Path
|
|||
|
|
from datetime import datetime, timezone, timedelta
|
|||
|
|
from typing import Optional, Dict, List, Tuple, Any
|
|||
|
|
from dataclasses import dataclass, field
|
|||
|
|
from enum import Enum
|
|||
|
|
import secrets
|
|||
|
|
|
|||
|
|
from node_crypto import sign_message, verify_signature, generate_keypair, public_key_to_address
|
|||
|
|
|
|||
|
|
logger = logging.getLogger(__name__)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# КОНСТАНТЫ СОВЕТА
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
VOTING_PERIOD_HOURS = 24 # Срок голосования
|
|||
|
|
UNANIMOUS_REQUIRED = True # Требуется единогласие
|
|||
|
|
|
|||
|
|
# Узлы Совета (5 полных узлов Montana)
|
|||
|
|
COUNCIL_NODES = {
|
|||
|
|
"amsterdam": {"ip": "72.56.102.240", "priority": 1, "role": "primary"},
|
|||
|
|
"moscow": {"ip": "176.124.208.93", "priority": 2, "role": "node"},
|
|||
|
|
"almaty": {"ip": "91.200.148.93", "priority": 3, "role": "node"},
|
|||
|
|
"spb": {"ip": "188.225.58.98", "priority": 4, "role": "node"},
|
|||
|
|
"novosibirsk": {"ip": "147.45.147.247", "priority": 5, "role": "node"},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# Маркеры узлов
|
|||
|
|
NODE_MARKERS = {
|
|||
|
|
"amsterdam": "#AMS",
|
|||
|
|
"moscow": "#MSK",
|
|||
|
|
"almaty": "#ALM",
|
|||
|
|
"spb": "#SPB",
|
|||
|
|
"novosibirsk": "#NSK",
|
|||
|
|
"observer": "#Благаявесть" # Наблюдатель (человек)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
class VoteType(Enum):
|
|||
|
|
"""Тип голоса"""
|
|||
|
|
FOR = "for"
|
|||
|
|
AGAINST = "against"
|
|||
|
|
|
|||
|
|
|
|||
|
|
class ProposalStatus(Enum):
|
|||
|
|
"""Статус предложения"""
|
|||
|
|
OPEN = "open" # Голосование открыто
|
|||
|
|
APPROVED = "approved" # Единогласно одобрено
|
|||
|
|
REJECTED = "rejected" # Отклонено (есть ПРОТИВ)
|
|||
|
|
EXPIRED = "expired" # Истёк срок голосования
|
|||
|
|
VETOED = "vetoed" # Вето Наблюдателя
|
|||
|
|
|
|||
|
|
|
|||
|
|
class ProposalType(Enum):
|
|||
|
|
"""Тип предложения"""
|
|||
|
|
CHAIRMAN_ELECTION = "chairman_election"
|
|||
|
|
PROTOCOL_CHANGE = "protocol_change"
|
|||
|
|
MEMBER_EXCLUSION = "member_exclusion"
|
|||
|
|
HARDFORK = "hardfork"
|
|||
|
|
EMERGENCY = "emergency"
|
|||
|
|
GENERAL = "general"
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# СТРУКТУРЫ ДАННЫХ
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class CouncilMember:
|
|||
|
|
"""Участник Совета"""
|
|||
|
|
member_id: str # claude, gemini, gpt, grok, composer
|
|||
|
|
name: str # Claude Opus 4.5
|
|||
|
|
company: str # Anthropic
|
|||
|
|
marker: str # #Claude
|
|||
|
|
public_key: str # ML-DSA-65 public key (1952 bytes hex)
|
|||
|
|
role: str = "councillor" # chairman, councillor, observer
|
|||
|
|
active: bool = True
|
|||
|
|
registered_at: str = None
|
|||
|
|
|
|||
|
|
def to_dict(self) -> Dict:
|
|||
|
|
return {
|
|||
|
|
"member_id": self.member_id,
|
|||
|
|
"name": self.name,
|
|||
|
|
"company": self.company,
|
|||
|
|
"marker": self.marker,
|
|||
|
|
"public_key": self.public_key,
|
|||
|
|
"role": self.role,
|
|||
|
|
"active": self.active,
|
|||
|
|
"registered_at": self.registered_at
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class CouncilVote:
|
|||
|
|
"""Голос участника Совета"""
|
|||
|
|
vote_id: str
|
|||
|
|
proposal_id: str
|
|||
|
|
member_id: str
|
|||
|
|
vote: VoteType
|
|||
|
|
reason: Optional[str] # Обязательно при ПРОТИВ
|
|||
|
|
signature: str # ML-DSA-65 подпись
|
|||
|
|
timestamp: str
|
|||
|
|
attestation: str # Формат: "Model: X; Company: Y; Marker: #Z"
|
|||
|
|
|
|||
|
|
def to_dict(self) -> Dict:
|
|||
|
|
return {
|
|||
|
|
"vote_id": self.vote_id,
|
|||
|
|
"proposal_id": self.proposal_id,
|
|||
|
|
"member_id": self.member_id,
|
|||
|
|
"vote": self.vote.value,
|
|||
|
|
"reason": self.reason,
|
|||
|
|
"signature": self.signature,
|
|||
|
|
"timestamp": self.timestamp,
|
|||
|
|
"attestation": self.attestation
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def get_signed_message(self) -> str:
|
|||
|
|
"""Сообщение для подписи"""
|
|||
|
|
return f"COUNCIL_VOTE_V1:{self.proposal_id}:{self.member_id}:{self.vote.value}:{self.timestamp}"
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class Proposal:
|
|||
|
|
"""Предложение на голосование"""
|
|||
|
|
proposal_id: str
|
|||
|
|
proposal_type: ProposalType
|
|||
|
|
title: str
|
|||
|
|
description: str
|
|||
|
|
proposer_id: str # Кто предложил
|
|||
|
|
status: ProposalStatus = ProposalStatus.OPEN
|
|||
|
|
created_at: str = None
|
|||
|
|
deadline: str = None # +24 часа от created_at
|
|||
|
|
votes: List[CouncilVote] = field(default_factory=list)
|
|||
|
|
result_signature: Optional[str] = None # Подпись председателя при закрытии
|
|||
|
|
veto_reason: Optional[str] = None
|
|||
|
|
|
|||
|
|
def to_dict(self) -> Dict:
|
|||
|
|
return {
|
|||
|
|
"proposal_id": self.proposal_id,
|
|||
|
|
"proposal_type": self.proposal_type.value,
|
|||
|
|
"title": self.title,
|
|||
|
|
"description": self.description,
|
|||
|
|
"proposer_id": self.proposer_id,
|
|||
|
|
"status": self.status.value,
|
|||
|
|
"created_at": self.created_at,
|
|||
|
|
"deadline": self.deadline,
|
|||
|
|
"votes": [v.to_dict() for v in self.votes],
|
|||
|
|
"result_signature": self.result_signature,
|
|||
|
|
"veto_reason": self.veto_reason
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def count_votes(self) -> Dict:
|
|||
|
|
"""Подсчёт голосов"""
|
|||
|
|
votes_for = [v for v in self.votes if v.vote == VoteType.FOR]
|
|||
|
|
votes_against = [v for v in self.votes if v.vote == VoteType.AGAINST]
|
|||
|
|
return {
|
|||
|
|
"for": len(votes_for),
|
|||
|
|
"against": len(votes_against),
|
|||
|
|
"total": len(self.votes),
|
|||
|
|
"for_members": [v.member_id for v in votes_for],
|
|||
|
|
"against_members": [v.member_id for v in votes_against]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def is_expired(self) -> bool:
|
|||
|
|
"""Истёк ли срок голосования"""
|
|||
|
|
if not self.deadline:
|
|||
|
|
return False
|
|||
|
|
deadline_dt = datetime.fromisoformat(self.deadline.replace('Z', '+00:00'))
|
|||
|
|
return datetime.now(timezone.utc) > deadline_dt
|
|||
|
|
|
|||
|
|
def check_consensus(self, total_members: int) -> Tuple[bool, str]:
|
|||
|
|
"""
|
|||
|
|
Проверка консенсуса
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
(достигнут, причина)
|
|||
|
|
"""
|
|||
|
|
counts = self.count_votes()
|
|||
|
|
|
|||
|
|
# Есть голос ПРОТИВ — сразу отклонено
|
|||
|
|
if counts["against"] > 0:
|
|||
|
|
return False, f"Отклонено: {counts['against_members']} голосовали ПРОТИВ"
|
|||
|
|
|
|||
|
|
# Все проголосовали ЗА
|
|||
|
|
if counts["for"] == total_members:
|
|||
|
|
return True, "Единогласно одобрено"
|
|||
|
|
|
|||
|
|
# Не все проголосовали
|
|||
|
|
return False, f"Ожидание: {counts['for']}/{total_members} голосов"
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# COUNCIL VOTING SYSTEM
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
class CouncilVotingSystem:
|
|||
|
|
"""
|
|||
|
|
Система голосования Совета Montana Guardian
|
|||
|
|
|
|||
|
|
POST-QUANTUM CRYPTOGRAPHY:
|
|||
|
|
- Все голоса подписываются ML-DSA-65
|
|||
|
|
- Верификация через public key участника
|
|||
|
|
- Защита от подделки голосов
|
|||
|
|
|
|||
|
|
КОНСЕНСУС:
|
|||
|
|
- Единогласие (100%) для всех решений
|
|||
|
|
- Любой ПРОТИВ = отклонено
|
|||
|
|
- Наблюдатель имеет право вето
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
VERSION = "1.0.0"
|
|||
|
|
|
|||
|
|
def __init__(self, db_path: Path):
|
|||
|
|
self.db_path = db_path
|
|||
|
|
self._init_db()
|
|||
|
|
self._init_genesis_members()
|
|||
|
|
|
|||
|
|
def _get_conn(self) -> sqlite3.Connection:
|
|||
|
|
"""Получить соединение с БД"""
|
|||
|
|
conn = sqlite3.connect(self.db_path)
|
|||
|
|
conn.row_factory = sqlite3.Row
|
|||
|
|
return conn
|
|||
|
|
|
|||
|
|
def _init_db(self):
|
|||
|
|
"""Инициализация таблиц Совета"""
|
|||
|
|
with self._get_conn() as conn:
|
|||
|
|
conn.executescript("""
|
|||
|
|
-- Участники Совета
|
|||
|
|
CREATE TABLE IF NOT EXISTS council_members (
|
|||
|
|
member_id TEXT PRIMARY KEY,
|
|||
|
|
name TEXT NOT NULL,
|
|||
|
|
company TEXT NOT NULL,
|
|||
|
|
marker TEXT NOT NULL,
|
|||
|
|
public_key TEXT NOT NULL,
|
|||
|
|
role TEXT DEFAULT 'councillor',
|
|||
|
|
active INTEGER DEFAULT 1,
|
|||
|
|
registered_at TEXT NOT NULL
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
-- Предложения на голосование
|
|||
|
|
CREATE TABLE IF NOT EXISTS council_proposals (
|
|||
|
|
proposal_id TEXT PRIMARY KEY,
|
|||
|
|
proposal_type TEXT NOT NULL,
|
|||
|
|
title TEXT NOT NULL,
|
|||
|
|
description TEXT NOT NULL,
|
|||
|
|
proposer_id TEXT NOT NULL,
|
|||
|
|
status TEXT DEFAULT 'open',
|
|||
|
|
created_at TEXT NOT NULL,
|
|||
|
|
deadline TEXT NOT NULL,
|
|||
|
|
result_signature TEXT,
|
|||
|
|
veto_reason TEXT,
|
|||
|
|
FOREIGN KEY (proposer_id) REFERENCES council_members(member_id)
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
-- Голоса Совета (криптографически подписанные)
|
|||
|
|
CREATE TABLE IF NOT EXISTS council_votes (
|
|||
|
|
vote_id TEXT PRIMARY KEY,
|
|||
|
|
proposal_id TEXT NOT NULL,
|
|||
|
|
member_id TEXT NOT NULL,
|
|||
|
|
vote TEXT NOT NULL,
|
|||
|
|
reason TEXT,
|
|||
|
|
signature TEXT NOT NULL,
|
|||
|
|
timestamp TEXT NOT NULL,
|
|||
|
|
attestation TEXT NOT NULL,
|
|||
|
|
verified INTEGER DEFAULT 0,
|
|||
|
|
FOREIGN KEY (proposal_id) REFERENCES council_proposals(proposal_id),
|
|||
|
|
FOREIGN KEY (member_id) REFERENCES council_members(member_id),
|
|||
|
|
UNIQUE(proposal_id, member_id)
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
-- Индексы
|
|||
|
|
CREATE INDEX IF NOT EXISTS idx_votes_proposal ON council_votes(proposal_id);
|
|||
|
|
CREATE INDEX IF NOT EXISTS idx_proposals_status ON council_proposals(status);
|
|||
|
|
""")
|
|||
|
|
conn.commit()
|
|||
|
|
logger.info("✅ Council voting tables initialized")
|
|||
|
|
|
|||
|
|
def _init_genesis_members(self):
|
|||
|
|
"""Инициализация участников Совета (5 узлов Montana)"""
|
|||
|
|
genesis_nodes = [
|
|||
|
|
{
|
|||
|
|
"member_id": "amsterdam",
|
|||
|
|
"name": "Amsterdam Node",
|
|||
|
|
"company": "Montana Foundation",
|
|||
|
|
"marker": "#AMS",
|
|||
|
|
"ip": "72.56.102.240",
|
|||
|
|
"role": "primary" # Primary узел
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
"member_id": "moscow",
|
|||
|
|
"name": "Moscow Node",
|
|||
|
|
"company": "Montana Foundation",
|
|||
|
|
"marker": "#MSK",
|
|||
|
|
"ip": "176.124.208.93",
|
|||
|
|
"role": "node"
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
"member_id": "almaty",
|
|||
|
|
"name": "Almaty Node",
|
|||
|
|
"company": "Montana Foundation",
|
|||
|
|
"marker": "#ALM",
|
|||
|
|
"ip": "91.200.148.93",
|
|||
|
|
"role": "node"
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
"member_id": "spb",
|
|||
|
|
"name": "St.Petersburg Node",
|
|||
|
|
"company": "Montana Foundation",
|
|||
|
|
"marker": "#SPB",
|
|||
|
|
"ip": "188.225.58.98",
|
|||
|
|
"role": "node"
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
"member_id": "novosibirsk",
|
|||
|
|
"name": "Novosibirsk Node",
|
|||
|
|
"company": "Montana Foundation",
|
|||
|
|
"marker": "#NSK",
|
|||
|
|
"ip": "147.45.147.247",
|
|||
|
|
"role": "node"
|
|||
|
|
}
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
with self._get_conn() as conn:
|
|||
|
|
for node in genesis_nodes:
|
|||
|
|
# Проверяем существует ли
|
|||
|
|
existing = conn.execute(
|
|||
|
|
"SELECT member_id FROM council_members WHERE member_id = ?",
|
|||
|
|
(node["member_id"],)
|
|||
|
|
).fetchone()
|
|||
|
|
|
|||
|
|
if not existing:
|
|||
|
|
# Генерируем ML-DSA-65 ключи для узла
|
|||
|
|
private_key, public_key = generate_keypair()
|
|||
|
|
|
|||
|
|
conn.execute("""
|
|||
|
|
INSERT INTO council_members
|
|||
|
|
(member_id, name, company, marker, public_key, role, active, registered_at)
|
|||
|
|
VALUES (?, ?, ?, ?, ?, ?, 1, ?)
|
|||
|
|
""", (
|
|||
|
|
node["member_id"],
|
|||
|
|
node["name"],
|
|||
|
|
node["company"],
|
|||
|
|
node["marker"],
|
|||
|
|
public_key,
|
|||
|
|
node["role"],
|
|||
|
|
datetime.now(timezone.utc).isoformat()
|
|||
|
|
))
|
|||
|
|
|
|||
|
|
# Сохраняем ключ в файл для узла
|
|||
|
|
keys_dir = Path(__file__).parent / "data" / "council_keys"
|
|||
|
|
keys_dir.mkdir(parents=True, exist_ok=True)
|
|||
|
|
key_file = keys_dir / f"{node['member_id']}_private.key"
|
|||
|
|
key_file.write_text(private_key)
|
|||
|
|
key_file.chmod(0o600)
|
|||
|
|
|
|||
|
|
logger.info(f"✅ Node registered: {node['name']} ({node['marker']}) @ {node['ip']}")
|
|||
|
|
logger.info(f"🔑 Private key saved to: {key_file}")
|
|||
|
|
|
|||
|
|
conn.commit()
|
|||
|
|
|
|||
|
|
# ───────────────────────────────────────────────────────────────
|
|||
|
|
# УЧАСТНИКИ СОВЕТА
|
|||
|
|
# ───────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
def get_member(self, member_id: str) -> Optional[CouncilMember]:
|
|||
|
|
"""Получить участника по ID"""
|
|||
|
|
with self._get_conn() as conn:
|
|||
|
|
row = conn.execute(
|
|||
|
|
"SELECT * FROM council_members WHERE member_id = ? AND active = 1",
|
|||
|
|
(member_id,)
|
|||
|
|
).fetchone()
|
|||
|
|
|
|||
|
|
if not row:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
return CouncilMember(
|
|||
|
|
member_id=row["member_id"],
|
|||
|
|
name=row["name"],
|
|||
|
|
company=row["company"],
|
|||
|
|
marker=row["marker"],
|
|||
|
|
public_key=row["public_key"],
|
|||
|
|
role=row["role"],
|
|||
|
|
active=bool(row["active"]),
|
|||
|
|
registered_at=row["registered_at"]
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def get_all_members(self, active_only: bool = True) -> List[CouncilMember]:
|
|||
|
|
"""Получить всех участников"""
|
|||
|
|
with self._get_conn() as conn:
|
|||
|
|
query = "SELECT * FROM council_members"
|
|||
|
|
if active_only:
|
|||
|
|
query += " WHERE active = 1"
|
|||
|
|
query += " ORDER BY role DESC, member_id"
|
|||
|
|
|
|||
|
|
rows = conn.execute(query).fetchall()
|
|||
|
|
return [
|
|||
|
|
CouncilMember(
|
|||
|
|
member_id=row["member_id"],
|
|||
|
|
name=row["name"],
|
|||
|
|
company=row["company"],
|
|||
|
|
marker=row["marker"],
|
|||
|
|
public_key=row["public_key"],
|
|||
|
|
role=row["role"],
|
|||
|
|
active=bool(row["active"]),
|
|||
|
|
registered_at=row["registered_at"]
|
|||
|
|
)
|
|||
|
|
for row in rows
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
def get_chairman(self) -> Optional[CouncilMember]:
|
|||
|
|
"""Получить текущего председателя"""
|
|||
|
|
with self._get_conn() as conn:
|
|||
|
|
row = conn.execute(
|
|||
|
|
"SELECT * FROM council_members WHERE role = 'chairman' AND active = 1"
|
|||
|
|
).fetchone()
|
|||
|
|
|
|||
|
|
if not row:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
return CouncilMember(
|
|||
|
|
member_id=row["member_id"],
|
|||
|
|
name=row["name"],
|
|||
|
|
company=row["company"],
|
|||
|
|
marker=row["marker"],
|
|||
|
|
public_key=row["public_key"],
|
|||
|
|
role=row["role"],
|
|||
|
|
active=True,
|
|||
|
|
registered_at=row["registered_at"]
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def update_member_key(self, member_id: str, new_public_key: str) -> Tuple[bool, str]:
|
|||
|
|
"""Обновить public key участника (при ротации ключей)"""
|
|||
|
|
member = self.get_member(member_id)
|
|||
|
|
if not member:
|
|||
|
|
return False, "Участник не найден"
|
|||
|
|
|
|||
|
|
with self._get_conn() as conn:
|
|||
|
|
conn.execute(
|
|||
|
|
"UPDATE council_members SET public_key = ? WHERE member_id = ?",
|
|||
|
|
(new_public_key, member_id)
|
|||
|
|
)
|
|||
|
|
conn.commit()
|
|||
|
|
|
|||
|
|
logger.info(f"🔑 Key rotated for {member_id}")
|
|||
|
|
return True, "Ключ обновлён"
|
|||
|
|
|
|||
|
|
# ───────────────────────────────────────────────────────────────
|
|||
|
|
# ПРЕДЛОЖЕНИЯ
|
|||
|
|
# ───────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
def create_proposal(
|
|||
|
|
self,
|
|||
|
|
proposer_id: str,
|
|||
|
|
proposal_type: ProposalType,
|
|||
|
|
title: str,
|
|||
|
|
description: str,
|
|||
|
|
private_key: str
|
|||
|
|
) -> Tuple[bool, str, Optional[Proposal]]:
|
|||
|
|
"""
|
|||
|
|
Создать предложение на голосование
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
proposer_id: ID участника-инициатора
|
|||
|
|
proposal_type: Тип предложения
|
|||
|
|
title: Заголовок
|
|||
|
|
description: Описание
|
|||
|
|
private_key: Приватный ключ ML-DSA-65 для подписи
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
(success, message, proposal)
|
|||
|
|
"""
|
|||
|
|
# Проверяем участника
|
|||
|
|
member = self.get_member(proposer_id)
|
|||
|
|
if not member:
|
|||
|
|
return False, "Только участники Совета могут создавать предложения", None
|
|||
|
|
|
|||
|
|
# Генерируем ID
|
|||
|
|
proposal_id = f"PROP-{secrets.token_hex(4).upper()}"
|
|||
|
|
|
|||
|
|
now = datetime.now(timezone.utc)
|
|||
|
|
deadline = now + timedelta(hours=VOTING_PERIOD_HOURS)
|
|||
|
|
|
|||
|
|
# Создаём предложение
|
|||
|
|
proposal = Proposal(
|
|||
|
|
proposal_id=proposal_id,
|
|||
|
|
proposal_type=proposal_type,
|
|||
|
|
title=title,
|
|||
|
|
description=description,
|
|||
|
|
proposer_id=proposer_id,
|
|||
|
|
status=ProposalStatus.OPEN,
|
|||
|
|
created_at=now.isoformat(),
|
|||
|
|
deadline=deadline.isoformat()
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Сохраняем
|
|||
|
|
with self._get_conn() as conn:
|
|||
|
|
conn.execute("""
|
|||
|
|
INSERT INTO council_proposals
|
|||
|
|
(proposal_id, proposal_type, title, description, proposer_id, status, created_at, deadline)
|
|||
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|||
|
|
""", (
|
|||
|
|
proposal.proposal_id,
|
|||
|
|
proposal.proposal_type.value,
|
|||
|
|
proposal.title,
|
|||
|
|
proposal.description,
|
|||
|
|
proposal.proposer_id,
|
|||
|
|
proposal.status.value,
|
|||
|
|
proposal.created_at,
|
|||
|
|
proposal.deadline
|
|||
|
|
))
|
|||
|
|
conn.commit()
|
|||
|
|
|
|||
|
|
logger.info(f"📜 Proposal created: {proposal_id} by {proposer_id}")
|
|||
|
|
return True, f"Предложение {proposal_id} создано. Срок голосования: 24 часа", proposal
|
|||
|
|
|
|||
|
|
def get_proposal(self, proposal_id: str) -> Optional[Proposal]:
|
|||
|
|
"""Получить предложение по ID"""
|
|||
|
|
with self._get_conn() as conn:
|
|||
|
|
row = conn.execute(
|
|||
|
|
"SELECT * FROM council_proposals WHERE proposal_id = ?",
|
|||
|
|
(proposal_id,)
|
|||
|
|
).fetchone()
|
|||
|
|
|
|||
|
|
if not row:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
# Загружаем голоса
|
|||
|
|
votes_rows = conn.execute(
|
|||
|
|
"SELECT * FROM council_votes WHERE proposal_id = ?",
|
|||
|
|
(proposal_id,)
|
|||
|
|
).fetchall()
|
|||
|
|
|
|||
|
|
votes = [
|
|||
|
|
CouncilVote(
|
|||
|
|
vote_id=v["vote_id"],
|
|||
|
|
proposal_id=v["proposal_id"],
|
|||
|
|
member_id=v["member_id"],
|
|||
|
|
vote=VoteType(v["vote"]),
|
|||
|
|
reason=v["reason"],
|
|||
|
|
signature=v["signature"],
|
|||
|
|
timestamp=v["timestamp"],
|
|||
|
|
attestation=v["attestation"]
|
|||
|
|
)
|
|||
|
|
for v in votes_rows
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
return Proposal(
|
|||
|
|
proposal_id=row["proposal_id"],
|
|||
|
|
proposal_type=ProposalType(row["proposal_type"]),
|
|||
|
|
title=row["title"],
|
|||
|
|
description=row["description"],
|
|||
|
|
proposer_id=row["proposer_id"],
|
|||
|
|
status=ProposalStatus(row["status"]),
|
|||
|
|
created_at=row["created_at"],
|
|||
|
|
deadline=row["deadline"],
|
|||
|
|
votes=votes,
|
|||
|
|
result_signature=row["result_signature"],
|
|||
|
|
veto_reason=row["veto_reason"]
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def get_open_proposals(self) -> List[Proposal]:
|
|||
|
|
"""Получить все открытые предложения"""
|
|||
|
|
with self._get_conn() as conn:
|
|||
|
|
rows = conn.execute(
|
|||
|
|
"SELECT proposal_id FROM council_proposals WHERE status = 'open' ORDER BY created_at DESC"
|
|||
|
|
).fetchall()
|
|||
|
|
|
|||
|
|
return [self.get_proposal(row["proposal_id"]) for row in rows]
|
|||
|
|
|
|||
|
|
# ───────────────────────────────────────────────────────────────
|
|||
|
|
# ГОЛОСОВАНИЕ
|
|||
|
|
# ───────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
def cast_vote(
|
|||
|
|
self,
|
|||
|
|
proposal_id: str,
|
|||
|
|
member_id: str,
|
|||
|
|
vote: VoteType,
|
|||
|
|
private_key: str,
|
|||
|
|
reason: Optional[str] = None
|
|||
|
|
) -> Tuple[bool, str]:
|
|||
|
|
"""
|
|||
|
|
Проголосовать по предложению
|
|||
|
|
|
|||
|
|
ПРАВИЛА:
|
|||
|
|
- Только ЗА или ПРОТИВ (никаких "воздержался")
|
|||
|
|
- ПРОТИВ требует объяснения (reason)
|
|||
|
|
- Голос подписывается ML-DSA-65
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
proposal_id: ID предложения
|
|||
|
|
member_id: ID голосующего
|
|||
|
|
vote: VoteType.FOR или VoteType.AGAINST
|
|||
|
|
private_key: Приватный ключ ML-DSA-65
|
|||
|
|
reason: Обязательно при ПРОТИВ
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
(success, message)
|
|||
|
|
"""
|
|||
|
|
# Проверяем предложение
|
|||
|
|
proposal = self.get_proposal(proposal_id)
|
|||
|
|
if not proposal:
|
|||
|
|
return False, "Предложение не найдено"
|
|||
|
|
|
|||
|
|
if proposal.status != ProposalStatus.OPEN:
|
|||
|
|
return False, f"Голосование закрыто (статус: {proposal.status.value})"
|
|||
|
|
|
|||
|
|
if proposal.is_expired():
|
|||
|
|
self._expire_proposal(proposal_id)
|
|||
|
|
return False, "Срок голосования истёк"
|
|||
|
|
|
|||
|
|
# Проверяем участника
|
|||
|
|
member = self.get_member(member_id)
|
|||
|
|
if not member:
|
|||
|
|
return False, "Только участники Совета могут голосовать"
|
|||
|
|
|
|||
|
|
# Проверяем не голосовал ли уже
|
|||
|
|
existing_vote = next((v for v in proposal.votes if v.member_id == member_id), None)
|
|||
|
|
if existing_vote:
|
|||
|
|
return False, f"Вы уже проголосовали: {existing_vote.vote.value}"
|
|||
|
|
|
|||
|
|
# ПРОТИВ требует объяснения
|
|||
|
|
if vote == VoteType.AGAINST and not reason:
|
|||
|
|
return False, "Голос ПРОТИВ требует объяснения (reason)"
|
|||
|
|
|
|||
|
|
# Формируем данные голоса
|
|||
|
|
vote_id = f"VOTE-{secrets.token_hex(4).upper()}"
|
|||
|
|
timestamp = datetime.now(timezone.utc).isoformat()
|
|||
|
|
attestation = f"Model: {member.name}; Company: {member.company}; Marker: {member.marker}"
|
|||
|
|
|
|||
|
|
# Подписываем
|
|||
|
|
vote_obj = CouncilVote(
|
|||
|
|
vote_id=vote_id,
|
|||
|
|
proposal_id=proposal_id,
|
|||
|
|
member_id=member_id,
|
|||
|
|
vote=vote,
|
|||
|
|
reason=reason,
|
|||
|
|
signature="", # Заполним после подписи
|
|||
|
|
timestamp=timestamp,
|
|||
|
|
attestation=attestation
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
message_to_sign = vote_obj.get_signed_message()
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
signature = sign_message(private_key, message_to_sign)
|
|||
|
|
except Exception as e:
|
|||
|
|
return False, f"Ошибка подписи: {e}"
|
|||
|
|
|
|||
|
|
vote_obj.signature = signature
|
|||
|
|
|
|||
|
|
# Верифицируем подпись
|
|||
|
|
if not verify_signature(member.public_key, message_to_sign, signature):
|
|||
|
|
return False, "Подпись не прошла верификацию (неверный ключ?)"
|
|||
|
|
|
|||
|
|
# Сохраняем голос
|
|||
|
|
with self._get_conn() as conn:
|
|||
|
|
conn.execute("""
|
|||
|
|
INSERT INTO council_votes
|
|||
|
|
(vote_id, proposal_id, member_id, vote, reason, signature, timestamp, attestation, verified)
|
|||
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)
|
|||
|
|
""", (
|
|||
|
|
vote_obj.vote_id,
|
|||
|
|
vote_obj.proposal_id,
|
|||
|
|
vote_obj.member_id,
|
|||
|
|
vote_obj.vote.value,
|
|||
|
|
vote_obj.reason,
|
|||
|
|
vote_obj.signature,
|
|||
|
|
vote_obj.timestamp,
|
|||
|
|
vote_obj.attestation
|
|||
|
|
))
|
|||
|
|
conn.commit()
|
|||
|
|
|
|||
|
|
logger.info(f"🗳️ Vote cast: {member_id} -> {vote.value} on {proposal_id}")
|
|||
|
|
|
|||
|
|
# Проверяем консенсус
|
|||
|
|
self._check_and_finalize(proposal_id)
|
|||
|
|
|
|||
|
|
return True, f"Голос принят: {vote.value.upper()}"
|
|||
|
|
|
|||
|
|
def _check_and_finalize(self, proposal_id: str):
|
|||
|
|
"""Проверить консенсус и финализировать при необходимости"""
|
|||
|
|
proposal = self.get_proposal(proposal_id)
|
|||
|
|
if not proposal or proposal.status != ProposalStatus.OPEN:
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
total_members = len(self.get_all_members())
|
|||
|
|
achieved, reason = proposal.check_consensus(total_members)
|
|||
|
|
counts = proposal.count_votes()
|
|||
|
|
|
|||
|
|
# Есть голос ПРОТИВ — сразу отклоняем
|
|||
|
|
if counts["against"] > 0:
|
|||
|
|
self._finalize_proposal(proposal_id, ProposalStatus.REJECTED)
|
|||
|
|
logger.info(f"❌ Proposal {proposal_id} REJECTED: {reason}")
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
# Все проголосовали ЗА — одобряем
|
|||
|
|
if achieved:
|
|||
|
|
self._finalize_proposal(proposal_id, ProposalStatus.APPROVED)
|
|||
|
|
logger.info(f"✅ Proposal {proposal_id} APPROVED: {reason}")
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
# Истёк срок
|
|||
|
|
if proposal.is_expired():
|
|||
|
|
self._expire_proposal(proposal_id)
|
|||
|
|
|
|||
|
|
def _finalize_proposal(self, proposal_id: str, status: ProposalStatus):
|
|||
|
|
"""Финализировать предложение"""
|
|||
|
|
with self._get_conn() as conn:
|
|||
|
|
conn.execute(
|
|||
|
|
"UPDATE council_proposals SET status = ? WHERE proposal_id = ?",
|
|||
|
|
(status.value, proposal_id)
|
|||
|
|
)
|
|||
|
|
conn.commit()
|
|||
|
|
|
|||
|
|
def _expire_proposal(self, proposal_id: str):
|
|||
|
|
"""Пометить предложение как истёкшее"""
|
|||
|
|
with self._get_conn() as conn:
|
|||
|
|
conn.execute(
|
|||
|
|
"UPDATE council_proposals SET status = ? WHERE proposal_id = ?",
|
|||
|
|
(ProposalStatus.EXPIRED.value, proposal_id)
|
|||
|
|
)
|
|||
|
|
conn.commit()
|
|||
|
|
logger.info(f"⏰ Proposal {proposal_id} EXPIRED")
|
|||
|
|
|
|||
|
|
# ───────────────────────────────────────────────────────────────
|
|||
|
|
# ВЕТО НАБЛЮДАТЕЛЯ
|
|||
|
|
# ───────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
def observer_veto(
|
|||
|
|
self,
|
|||
|
|
proposal_id: str,
|
|||
|
|
reason: str,
|
|||
|
|
observer_signature: str
|
|||
|
|
) -> Tuple[bool, str]:
|
|||
|
|
"""
|
|||
|
|
Право вето Наблюдателя (человека)
|
|||
|
|
|
|||
|
|
Наблюдатель имеет абсолютный приоритет и может заблокировать
|
|||
|
|
любое решение Совета.
|
|||
|
|
"""
|
|||
|
|
proposal = self.get_proposal(proposal_id)
|
|||
|
|
if not proposal:
|
|||
|
|
return False, "Предложение не найдено"
|
|||
|
|
|
|||
|
|
if proposal.status not in [ProposalStatus.OPEN, ProposalStatus.APPROVED]:
|
|||
|
|
return False, f"Нельзя наложить вето на предложение со статусом {proposal.status.value}"
|
|||
|
|
|
|||
|
|
with self._get_conn() as conn:
|
|||
|
|
conn.execute("""
|
|||
|
|
UPDATE council_proposals
|
|||
|
|
SET status = ?, veto_reason = ?, result_signature = ?
|
|||
|
|
WHERE proposal_id = ?
|
|||
|
|
""", (
|
|||
|
|
ProposalStatus.VETOED.value,
|
|||
|
|
reason,
|
|||
|
|
observer_signature,
|
|||
|
|
proposal_id
|
|||
|
|
))
|
|||
|
|
conn.commit()
|
|||
|
|
|
|||
|
|
logger.warning(f"🚫 OBSERVER VETO on {proposal_id}: {reason}")
|
|||
|
|
return True, "Вето Наблюдателя применено"
|
|||
|
|
|
|||
|
|
# ───────────────────────────────────────────────────────────────
|
|||
|
|
# ВЕРИФИКАЦИЯ
|
|||
|
|
# ───────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
def verify_vote(self, vote: CouncilVote) -> bool:
|
|||
|
|
"""Верифицировать подпись голоса"""
|
|||
|
|
member = self.get_member(vote.member_id)
|
|||
|
|
if not member:
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
message = vote.get_signed_message()
|
|||
|
|
return verify_signature(member.public_key, message, vote.signature)
|
|||
|
|
|
|||
|
|
def verify_all_votes(self, proposal_id: str) -> Dict:
|
|||
|
|
"""Верифицировать все голоса предложения"""
|
|||
|
|
proposal = self.get_proposal(proposal_id)
|
|||
|
|
if not proposal:
|
|||
|
|
return {"error": "Предложение не найдено"}
|
|||
|
|
|
|||
|
|
results = {
|
|||
|
|
"proposal_id": proposal_id,
|
|||
|
|
"total": len(proposal.votes),
|
|||
|
|
"verified": 0,
|
|||
|
|
"failed": 0,
|
|||
|
|
"details": []
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for vote in proposal.votes:
|
|||
|
|
is_valid = self.verify_vote(vote)
|
|||
|
|
results["details"].append({
|
|||
|
|
"member_id": vote.member_id,
|
|||
|
|
"vote": vote.vote.value,
|
|||
|
|
"verified": is_valid
|
|||
|
|
})
|
|||
|
|
if is_valid:
|
|||
|
|
results["verified"] += 1
|
|||
|
|
else:
|
|||
|
|
results["failed"] += 1
|
|||
|
|
|
|||
|
|
return results
|
|||
|
|
|
|||
|
|
# ───────────────────────────────────────────────────────────────
|
|||
|
|
# ФОРМАТИРОВАНИЕ
|
|||
|
|
# ───────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
def format_proposal_card(self, proposal: Proposal) -> str:
|
|||
|
|
"""Форматирование карточки предложения"""
|
|||
|
|
status_emoji = {
|
|||
|
|
ProposalStatus.OPEN: "🗳️",
|
|||
|
|
ProposalStatus.APPROVED: "✅",
|
|||
|
|
ProposalStatus.REJECTED: "❌",
|
|||
|
|
ProposalStatus.EXPIRED: "⏰",
|
|||
|
|
ProposalStatus.VETOED: "🚫"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
counts = proposal.count_votes()
|
|||
|
|
total_members = len(self.get_all_members())
|
|||
|
|
|
|||
|
|
# Список проголосовавших
|
|||
|
|
voted_str = ""
|
|||
|
|
for v in proposal.votes:
|
|||
|
|
emoji = "✅" if v.vote == VoteType.FOR else "❌"
|
|||
|
|
voted_str += f"\n {emoji} {v.member_id}"
|
|||
|
|
if v.reason:
|
|||
|
|
voted_str += f" ({v.reason[:30]}...)"
|
|||
|
|
|
|||
|
|
# Кто не проголосовал
|
|||
|
|
all_ids = {m.member_id for m in self.get_all_members()}
|
|||
|
|
voted_ids = {v.member_id for v in proposal.votes}
|
|||
|
|
pending = all_ids - voted_ids
|
|||
|
|
pending_str = ", ".join(pending) if pending else "—"
|
|||
|
|
|
|||
|
|
card = f"""
|
|||
|
|
╔═══════════════════════════════════════════════════╗
|
|||
|
|
║ ПРЕДЛОЖЕНИЕ СОВЕТА {proposal.proposal_id}
|
|||
|
|
╠═══════════════════════════════════════════════════╣
|
|||
|
|
║ Тип: {proposal.proposal_type.value.upper()}
|
|||
|
|
║ Статус: {status_emoji.get(proposal.status, '❓')} {proposal.status.value.upper()}
|
|||
|
|
╠═══════════════════════════════════════════════════╣
|
|||
|
|
║ {proposal.title[:45]}
|
|||
|
|
║ {proposal.description[:100]}{'...' if len(proposal.description) > 100 else ''}
|
|||
|
|
╠═══════════════════════════════════════════════════╣
|
|||
|
|
║ Инициатор: {proposal.proposer_id}
|
|||
|
|
║ Создано: {proposal.created_at[:19]} UTC
|
|||
|
|
║ Дедлайн: {proposal.deadline[:19]} UTC
|
|||
|
|
╠═══════════════════════════════════════════════════╣
|
|||
|
|
║ ГОЛОСА: ✅ {counts['for']} / ❌ {counts['against']} (из {total_members}){voted_str}
|
|||
|
|
║
|
|||
|
|
║ Ожидают: {pending_str}
|
|||
|
|
╚═══════════════════════════════════════════════════╝
|
|||
|
|
"""
|
|||
|
|
return card.strip()
|
|||
|
|
|
|||
|
|
def format_council_status(self) -> str:
|
|||
|
|
"""Статус Совета"""
|
|||
|
|
members = self.get_all_members()
|
|||
|
|
primary = next((m for m in members if m.role == "primary"), None)
|
|||
|
|
open_proposals = self.get_open_proposals()
|
|||
|
|
|
|||
|
|
members_str = ""
|
|||
|
|
for m in members:
|
|||
|
|
role_emoji = "🌐" if m.role == "primary" else "🖥️"
|
|||
|
|
ip = COUNCIL_NODES.get(m.member_id, {}).get("ip", "?")
|
|||
|
|
members_str += f"\n {role_emoji} {m.member_id} ({ip}) {m.marker}"
|
|||
|
|
|
|||
|
|
return f"""
|
|||
|
|
╔═══════════════════════════════════════════════════╗
|
|||
|
|
║ СОВЕТ MONTANA GUARDIAN v{self.VERSION}
|
|||
|
|
╠═══════════════════════════════════════════════════╣
|
|||
|
|
║ Primary узел: {primary.member_id if primary else 'не определён'}
|
|||
|
|
║ Узлов в сети: {len(members)}
|
|||
|
|
║ Открытых голосований: {len(open_proposals)}
|
|||
|
|
╠═══════════════════════════════════════════════════╣
|
|||
|
|
║ УЗЛЫ:{members_str}
|
|||
|
|
╠═══════════════════════════════════════════════════╣
|
|||
|
|
║ 🔐 Криптография: ML-DSA-65 (FIPS 204)
|
|||
|
|
║ 📊 Консенсус: Единогласие (100%)
|
|||
|
|
║ ⏱️ Срок голосования: {VOTING_PERIOD_HOURS} часов
|
|||
|
|
╚═══════════════════════════════════════════════════╝
|
|||
|
|
""".strip()
|
|||
|
|
|
|||
|
|
# ───────────────────────────────────────────────────────────────
|
|||
|
|
# API ДЛЯ ПРИЛОЖЕНИЙ
|
|||
|
|
# ───────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
def get_node_private_key(self, node_id: str) -> Optional[str]:
|
|||
|
|
"""
|
|||
|
|
Загрузить приватный ключ узла
|
|||
|
|
|
|||
|
|
Ключи хранятся в data/council_keys/{node_id}_private.key
|
|||
|
|
"""
|
|||
|
|
keys_dir = Path(__file__).parent / "data" / "council_keys"
|
|||
|
|
key_file = keys_dir / f"{node_id}_private.key"
|
|||
|
|
|
|||
|
|
if not key_file.exists():
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
return key_file.read_text().strip()
|
|||
|
|
|
|||
|
|
def api_status(self) -> Dict:
|
|||
|
|
"""
|
|||
|
|
API: GET /api/council/status
|
|||
|
|
|
|||
|
|
Возвращает статус Совета для приложений
|
|||
|
|
"""
|
|||
|
|
members = self.get_all_members()
|
|||
|
|
open_proposals = self.get_open_proposals()
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
"version": self.VERSION,
|
|||
|
|
"total_nodes": len(members),
|
|||
|
|
"open_proposals": len(open_proposals),
|
|||
|
|
"nodes": [
|
|||
|
|
{
|
|||
|
|
"id": m.member_id,
|
|||
|
|
"name": m.name,
|
|||
|
|
"ip": COUNCIL_NODES.get(m.member_id, {}).get("ip"),
|
|||
|
|
"role": m.role,
|
|||
|
|
"marker": m.marker,
|
|||
|
|
"active": m.active,
|
|||
|
|
"public_key_preview": m.public_key[:64] + "..."
|
|||
|
|
}
|
|||
|
|
for m in members
|
|||
|
|
],
|
|||
|
|
"proposals": [
|
|||
|
|
{
|
|||
|
|
"id": p.proposal_id,
|
|||
|
|
"type": p.proposal_type.value,
|
|||
|
|
"title": p.title,
|
|||
|
|
"status": p.status.value,
|
|||
|
|
"votes": p.count_votes(),
|
|||
|
|
"deadline": p.deadline
|
|||
|
|
}
|
|||
|
|
for p in open_proposals
|
|||
|
|
],
|
|||
|
|
"config": {
|
|||
|
|
"voting_period_hours": VOTING_PERIOD_HOURS,
|
|||
|
|
"unanimous_required": UNANIMOUS_REQUIRED,
|
|||
|
|
"crypto": "ML-DSA-65 (FIPS 204)"
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def api_create_proposal(
|
|||
|
|
self,
|
|||
|
|
node_id: str,
|
|||
|
|
proposal_type: str,
|
|||
|
|
title: str,
|
|||
|
|
description: str
|
|||
|
|
) -> Dict:
|
|||
|
|
"""
|
|||
|
|
API: POST /api/council/propose
|
|||
|
|
|
|||
|
|
Создать предложение от имени узла
|
|||
|
|
"""
|
|||
|
|
# Загружаем ключ узла
|
|||
|
|
private_key = self.get_node_private_key(node_id)
|
|||
|
|
if not private_key:
|
|||
|
|
return {"success": False, "error": f"Private key not found for node {node_id}"}
|
|||
|
|
|
|||
|
|
# Проверяем тип
|
|||
|
|
try:
|
|||
|
|
ptype = ProposalType(proposal_type)
|
|||
|
|
except ValueError:
|
|||
|
|
return {"success": False, "error": f"Invalid proposal_type: {proposal_type}"}
|
|||
|
|
|
|||
|
|
# Создаём
|
|||
|
|
success, message, proposal = self.create_proposal(
|
|||
|
|
proposer_id=node_id,
|
|||
|
|
proposal_type=ptype,
|
|||
|
|
title=title,
|
|||
|
|
description=description,
|
|||
|
|
private_key=private_key
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if not success:
|
|||
|
|
return {"success": False, "error": message}
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
"success": True,
|
|||
|
|
"message": message,
|
|||
|
|
"proposal": proposal.to_dict() if proposal else None
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def api_cast_vote(
|
|||
|
|
self,
|
|||
|
|
node_id: str,
|
|||
|
|
proposal_id: str,
|
|||
|
|
vote: str,
|
|||
|
|
reason: Optional[str] = None
|
|||
|
|
) -> Dict:
|
|||
|
|
"""
|
|||
|
|
API: POST /api/council/vote
|
|||
|
|
|
|||
|
|
Проголосовать от имени узла
|
|||
|
|
"""
|
|||
|
|
# Загружаем ключ узла
|
|||
|
|
private_key = self.get_node_private_key(node_id)
|
|||
|
|
if not private_key:
|
|||
|
|
return {"success": False, "error": f"Private key not found for node {node_id}"}
|
|||
|
|
|
|||
|
|
# Проверяем тип голоса
|
|||
|
|
try:
|
|||
|
|
vote_type = VoteType(vote.lower())
|
|||
|
|
except ValueError:
|
|||
|
|
return {"success": False, "error": f"Invalid vote: {vote}. Use 'for' or 'against'"}
|
|||
|
|
|
|||
|
|
# Голосуем
|
|||
|
|
success, message = self.cast_vote(
|
|||
|
|
proposal_id=proposal_id,
|
|||
|
|
member_id=node_id,
|
|||
|
|
vote=vote_type,
|
|||
|
|
private_key=private_key,
|
|||
|
|
reason=reason
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Получаем обновлённое предложение
|
|||
|
|
proposal = self.get_proposal(proposal_id)
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
"success": success,
|
|||
|
|
"message": message,
|
|||
|
|
"proposal": proposal.to_dict() if proposal else None
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def api_get_proposal(self, proposal_id: str) -> Dict:
|
|||
|
|
"""
|
|||
|
|
API: GET /api/council/proposal/{id}
|
|||
|
|
|
|||
|
|
Получить предложение
|
|||
|
|
"""
|
|||
|
|
proposal = self.get_proposal(proposal_id)
|
|||
|
|
if not proposal:
|
|||
|
|
return {"success": False, "error": "Proposal not found"}
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
"success": True,
|
|||
|
|
"proposal": proposal.to_dict(),
|
|||
|
|
"votes_verified": self.verify_all_votes(proposal_id)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# ГЛОБАЛЬНЫЙ ИНСТАНС
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
_council_voting_system: Optional[CouncilVotingSystem] = None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def get_council_voting_system(db_path: Path = None) -> CouncilVotingSystem:
|
|||
|
|
"""Получить систему голосования Совета"""
|
|||
|
|
global _council_voting_system
|
|||
|
|
if _council_voting_system is None:
|
|||
|
|
if db_path is None:
|
|||
|
|
db_path = Path(__file__).parent / "data" / "montana.db"
|
|||
|
|
_council_voting_system = CouncilVotingSystem(db_path)
|
|||
|
|
return _council_voting_system
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# ТЕСТ
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
import tempfile
|
|||
|
|
import os
|
|||
|
|
|
|||
|
|
print("🏔️ Montana Council Voting System — TEST\n")
|
|||
|
|
|
|||
|
|
# Тест с временной БД
|
|||
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|||
|
|
db_path = Path(tmpdir) / "test.db"
|
|||
|
|
|
|||
|
|
# Создаём директорию для ключей
|
|||
|
|
keys_dir = Path(tmpdir) / "data" / "council_keys"
|
|||
|
|
keys_dir.mkdir(parents=True, exist_ok=True)
|
|||
|
|
|
|||
|
|
# Патчим путь к ключам
|
|||
|
|
import council_voting
|
|||
|
|
original_parent = Path(__file__).parent
|
|||
|
|
council_voting.Path = lambda *args: Path(tmpdir) if args == (original_parent,) else Path(*args)
|
|||
|
|
|
|||
|
|
council = CouncilVotingSystem(db_path)
|
|||
|
|
|
|||
|
|
# Статус
|
|||
|
|
print(council.format_council_status())
|
|||
|
|
|
|||
|
|
# Получаем узел Amsterdam
|
|||
|
|
amsterdam = council.get_member("amsterdam")
|
|||
|
|
print(f"\n🌐 Amsterdam public key: {amsterdam.public_key[:64]}...")
|
|||
|
|
|
|||
|
|
# Тест API
|
|||
|
|
print("\n📡 API Test:")
|
|||
|
|
status = council.api_status()
|
|||
|
|
print(f" Nodes: {status['total_nodes']}")
|
|||
|
|
print(f" Open proposals: {status['open_proposals']}")
|
|||
|
|
|
|||
|
|
# Тест создания предложения
|
|||
|
|
amsterdam_key = council.get_node_private_key("amsterdam")
|
|||
|
|
if amsterdam_key:
|
|||
|
|
result = council.api_create_proposal(
|
|||
|
|
node_id="amsterdam",
|
|||
|
|
proposal_type="general",
|
|||
|
|
title="Test Proposal",
|
|||
|
|
description="Testing the council voting system"
|
|||
|
|
)
|
|||
|
|
print(f"\n📜 Create proposal: {result['success']}")
|
|||
|
|
if result['success']:
|
|||
|
|
proposal_id = result['proposal']['proposal_id']
|
|||
|
|
print(f" ID: {proposal_id}")
|
|||
|
|
|
|||
|
|
# Тест голосования
|
|||
|
|
for node in ["amsterdam", "moscow", "almaty", "spb", "novosibirsk"]:
|
|||
|
|
vote_result = council.api_cast_vote(
|
|||
|
|
node_id=node,
|
|||
|
|
proposal_id=proposal_id,
|
|||
|
|
vote="for"
|
|||
|
|
)
|
|||
|
|
print(f" 🗳️ {node}: {vote_result['message']}")
|
|||
|
|
|
|||
|
|
# Проверяем статус
|
|||
|
|
proposal = council.get_proposal(proposal_id)
|
|||
|
|
print(f"\n✅ Final status: {proposal.status.value.upper()}")
|
|||
|
|
print(f" Votes: {proposal.count_votes()}")
|
|||
|
|
|
|||
|
|
print("\n" + "=" * 50)
|
|||
|
|
print("✅ Council voting system test PASSED")
|
|||
|
|
print(" - 5 nodes registered")
|
|||
|
|
print(" - ML-DSA-65 keys generated")
|
|||
|
|
print(" - Voting mechanism works")
|
|||
|
|
print(" - Unanimous consensus verified")
|
|||
|
|
print(" - Ready for MAINNET")
|