903 lines
42 KiB
Python
903 lines
42 KiB
Python
|
|
#!/usr/bin/env python3
|
|||
|
|
"""
|
|||
|
|
КОНТРАКТЫ MONTANA — Смарт-контракты времени
|
|||
|
|
============================================
|
|||
|
|
|
|||
|
|
Юнона = арбитр, свидетель, валидатор сделок.
|
|||
|
|
Группа = контрактная комната (до 12 свидетелей).
|
|||
|
|
|
|||
|
|
Механика создания:
|
|||
|
|
1. Диалог создания (Юнона ведёт)
|
|||
|
|
2. Голосование участников (>50% за)
|
|||
|
|
3. Юнона проверяет условия (ПОСЛЕДНЕЕ СЛОВО)
|
|||
|
|
4. Escrow (заморозка средств)
|
|||
|
|
5. Сторона Б принимает
|
|||
|
|
|
|||
|
|
Механика завершения (Bitcoin Pizza Style):
|
|||
|
|
1. Сторона заявляет о выполнении (/contract done)
|
|||
|
|
2. Стороны предоставляют доказательства (фото/видео/документы)
|
|||
|
|
3. ВСЕ участники группы голосуют за валидность
|
|||
|
|
4. Юнона = 2 голоса, свидетели = 1 голос
|
|||
|
|
5. При кворуме + одобрении Юноны → COMPLETED
|
|||
|
|
|
|||
|
|
Статусы: draft → pending → accepted → completion_voting → completed
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import json
|
|||
|
|
import secrets
|
|||
|
|
from datetime import datetime, timezone
|
|||
|
|
from typing import Optional, Dict, List, Tuple, Any
|
|||
|
|
from dataclasses import dataclass, field
|
|||
|
|
from enum import Enum
|
|||
|
|
|
|||
|
|
|
|||
|
|
class ContractStatus(Enum):
|
|||
|
|
"""Статусы контракта"""
|
|||
|
|
DRAFT = "draft" # Черновик — ещё собирает голоса
|
|||
|
|
PENDING = "pending" # Ожидает подтверждения Стороны Б
|
|||
|
|
ACCEPTED = "accepted" # Принят, условия исполняются
|
|||
|
|
COMPLETION_VOTING = "completion_voting" # Голосование за завершение
|
|||
|
|
COMPLETED = "completed" # Исполнен, средства переведены
|
|||
|
|
CANCELLED = "cancelled" # Отменён, средства разморожены
|
|||
|
|
REJECTED = "rejected" # Отклонён большинством или Юноной
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class ContractParty:
|
|||
|
|
"""Сторона контракта"""
|
|||
|
|
user_id: str
|
|||
|
|
username: Optional[str] = None
|
|||
|
|
name: Optional[str] = None # Имя из адресной книги
|
|||
|
|
|
|||
|
|
def display_name(self) -> str:
|
|||
|
|
if self.username:
|
|||
|
|
return f"@{self.username}"
|
|||
|
|
if self.name:
|
|||
|
|
return self.name
|
|||
|
|
return str(self.user_id)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# Константы голосования
|
|||
|
|
JUNONA_VOTE_WEIGHT = 2 # Юнона = 2 голоса
|
|||
|
|
WITNESS_VOTE_WEIGHT = 1 # Свидетель = 1 голос
|
|||
|
|
JUNONA_USER_ID = "JUNONA" # Специальный ID для голосов Юноны
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class Contract:
|
|||
|
|
"""Контракт Montana
|
|||
|
|
|
|||
|
|
Механика голосования (создание):
|
|||
|
|
1. Создаётся в статусе DRAFT
|
|||
|
|
2. Участники голосуют (approve/reject)
|
|||
|
|
3. Когда большинство (>50%) одобрили — Юнона проверяет
|
|||
|
|
4. Юнона имеет ПОСЛЕДНЕЕ СЛОВО (может отклонить даже с кворумом)
|
|||
|
|
5. После одобрения Юноной → PENDING (ждёт Сторону Б)
|
|||
|
|
|
|||
|
|
Механика голосования (завершение):
|
|||
|
|
1. Сторона А заявляет о выполнении → COMPLETION_VOTING
|
|||
|
|
2. Стороны предоставляют доказательства (фото/видео/документы)
|
|||
|
|
3. Все участники голосуют за валидность исполнения
|
|||
|
|
4. Юнона = 2 голоса, свидетели = 1 голос
|
|||
|
|
5. При достижении большинства + одобрении Юноны → COMPLETED
|
|||
|
|
"""
|
|||
|
|
contract_id: str
|
|||
|
|
creator: ContractParty # Сторона А (отправитель)
|
|||
|
|
target: ContractParty # Сторона Б (получатель)
|
|||
|
|
amount: int # Сумма в Ɉ
|
|||
|
|
description: str # Условие контракта
|
|||
|
|
status: ContractStatus = ContractStatus.DRAFT
|
|||
|
|
chat_id: Optional[str] = None # ID группы (если групповой)
|
|||
|
|
witnesses: List[str] = field(default_factory=list) # ID участников комнаты
|
|||
|
|
votes_approve: List[str] = field(default_factory=list) # ID тех кто одобрил создание
|
|||
|
|
votes_reject: List[str] = field(default_factory=list) # ID тех кто отклонил создание
|
|||
|
|
junona_approved: Optional[bool] = None # Последнее слово Юноны (создание)
|
|||
|
|
junona_reason: Optional[str] = None # Причина решения Юноны
|
|||
|
|
# Голосование за завершение
|
|||
|
|
completion_votes_approve: List[str] = field(default_factory=list) # За исполнение
|
|||
|
|
completion_votes_reject: List[str] = field(default_factory=list) # Против исполнения
|
|||
|
|
junona_completion_approved: Optional[bool] = None # Юнона подтвердила завершение
|
|||
|
|
junona_completion_reason: Optional[str] = None # Причина решения по завершению
|
|||
|
|
created_at: Optional[str] = None
|
|||
|
|
accepted_at: Optional[str] = None
|
|||
|
|
completed_at: Optional[str] = None
|
|||
|
|
cancelled_at: Optional[str] = None
|
|||
|
|
escrow_tx_id: Optional[str] = None
|
|||
|
|
|
|||
|
|
def to_dict(self) -> Dict:
|
|||
|
|
return {
|
|||
|
|
"contract_id": self.contract_id,
|
|||
|
|
"creator_id": self.creator.user_id,
|
|||
|
|
"creator_username": self.creator.username,
|
|||
|
|
"target_id": self.target.user_id,
|
|||
|
|
"target_username": self.target.username,
|
|||
|
|
"amount": self.amount,
|
|||
|
|
"description": self.description,
|
|||
|
|
"status": self.status.value,
|
|||
|
|
"chat_id": self.chat_id,
|
|||
|
|
"witnesses": self.witnesses,
|
|||
|
|
"votes_approve": self.votes_approve,
|
|||
|
|
"votes_reject": self.votes_reject,
|
|||
|
|
"junona_approved": self.junona_approved,
|
|||
|
|
"junona_reason": self.junona_reason,
|
|||
|
|
# Голосование за завершение
|
|||
|
|
"completion_votes_approve": self.completion_votes_approve,
|
|||
|
|
"completion_votes_reject": self.completion_votes_reject,
|
|||
|
|
"junona_completion_approved": self.junona_completion_approved,
|
|||
|
|
"junona_completion_reason": self.junona_completion_reason,
|
|||
|
|
"created_at": self.created_at,
|
|||
|
|
"accepted_at": self.accepted_at,
|
|||
|
|
"completed_at": self.completed_at,
|
|||
|
|
"cancelled_at": self.cancelled_at,
|
|||
|
|
"escrow_tx_id": self.escrow_tx_id
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# ───────────────────────────────────────────────────────────────
|
|||
|
|
# КОНСЕНСУС: Большинство + Юнона
|
|||
|
|
# ───────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
def vote(self, user_id: str, approve: bool) -> Tuple[bool, str]:
|
|||
|
|
"""Участник голосует за/против контракта
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
(success, message)
|
|||
|
|
"""
|
|||
|
|
# Можно голосовать только в статусе DRAFT
|
|||
|
|
if self.status != ContractStatus.DRAFT:
|
|||
|
|
return False, "Голосование закрыто"
|
|||
|
|
|
|||
|
|
# Только участники комнаты могут голосовать
|
|||
|
|
if user_id not in self.witnesses:
|
|||
|
|
return False, "Только участники комнаты могут голосовать"
|
|||
|
|
|
|||
|
|
# Убираем из предыдущего списка если передумал
|
|||
|
|
if user_id in self.votes_approve:
|
|||
|
|
self.votes_approve.remove(user_id)
|
|||
|
|
if user_id in self.votes_reject:
|
|||
|
|
self.votes_reject.remove(user_id)
|
|||
|
|
|
|||
|
|
# Добавляем голос
|
|||
|
|
if approve:
|
|||
|
|
self.votes_approve.append(user_id)
|
|||
|
|
else:
|
|||
|
|
self.votes_reject.append(user_id)
|
|||
|
|
|
|||
|
|
return True, "Голос принят"
|
|||
|
|
|
|||
|
|
def get_quorum_status(self) -> Dict:
|
|||
|
|
"""Статус кворума
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
{
|
|||
|
|
"total": количество участников,
|
|||
|
|
"voted": проголосовало,
|
|||
|
|
"approve": за,
|
|||
|
|
"reject": против,
|
|||
|
|
"has_quorum": достигнут ли кворум (>50% за),
|
|||
|
|
"ready_for_junona": можно ли отдать на решение Юноне
|
|||
|
|
}
|
|||
|
|
"""
|
|||
|
|
total = len(self.witnesses) if self.witnesses else 1
|
|||
|
|
approve = len(self.votes_approve)
|
|||
|
|
reject = len(self.votes_reject)
|
|||
|
|
voted = approve + reject
|
|||
|
|
|
|||
|
|
# Кворум = большинство (>50%) проголосовало ЗА
|
|||
|
|
has_quorum = approve > total / 2
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
"total": total,
|
|||
|
|
"voted": voted,
|
|||
|
|
"approve": approve,
|
|||
|
|
"reject": reject,
|
|||
|
|
"has_quorum": has_quorum,
|
|||
|
|
"ready_for_junona": has_quorum # Юнона решает когда есть кворум
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def junona_decision(self, approve: bool, reason: str = None) -> Tuple[bool, str]:
|
|||
|
|
"""Юнона принимает финальное решение
|
|||
|
|
|
|||
|
|
ПОСЛЕДНЕЕ СЛОВО: Юнона может отклонить даже с кворумом
|
|||
|
|
если условия невыполнимы или опасны.
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
(success, message)
|
|||
|
|
"""
|
|||
|
|
if self.status != ContractStatus.DRAFT:
|
|||
|
|
return False, "Контракт не в статусе ожидания"
|
|||
|
|
|
|||
|
|
quorum = self.get_quorum_status()
|
|||
|
|
if not quorum["ready_for_junona"]:
|
|||
|
|
return False, f"Нет кворума: {quorum['approve']}/{quorum['total']} (нужно >{quorum['total']//2})"
|
|||
|
|
|
|||
|
|
self.junona_approved = approve
|
|||
|
|
self.junona_reason = reason
|
|||
|
|
|
|||
|
|
if approve:
|
|||
|
|
self.status = ContractStatus.PENDING
|
|||
|
|
return True, "Контракт одобрен. Ожидает подтверждения Стороны Б"
|
|||
|
|
else:
|
|||
|
|
self.status = ContractStatus.REJECTED
|
|||
|
|
return True, f"Контракт отклонён Юноной: {reason or 'условия невыполнимы'}"
|
|||
|
|
|
|||
|
|
# ───────────────────────────────────────────────────────────────
|
|||
|
|
# ГОЛОСОВАНИЕ ЗА ЗАВЕРШЕНИЕ (Bitcoin Pizza Style)
|
|||
|
|
# ───────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
def start_completion_voting(self) -> Tuple[bool, str]:
|
|||
|
|
"""Запустить голосование за завершение
|
|||
|
|
|
|||
|
|
Вызывается когда сторона заявляет о выполнении условий.
|
|||
|
|
Переводит контракт в статус COMPLETION_VOTING.
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
(success, message)
|
|||
|
|
"""
|
|||
|
|
if self.status != ContractStatus.ACCEPTED:
|
|||
|
|
return False, f"Голосование можно начать только для принятого контракта (сейчас: {self.status.value})"
|
|||
|
|
|
|||
|
|
self.status = ContractStatus.COMPLETION_VOTING
|
|||
|
|
self.completion_votes_approve = []
|
|||
|
|
self.completion_votes_reject = []
|
|||
|
|
self.junona_completion_approved = None
|
|||
|
|
self.junona_completion_reason = None
|
|||
|
|
|
|||
|
|
return True, "Голосование за завершение началось. Свидетели, проверьте доказательства!"
|
|||
|
|
|
|||
|
|
def vote_completion(self, user_id: str, approve: bool, is_junona: bool = False) -> Tuple[bool, str]:
|
|||
|
|
"""Голосование за завершение контракта
|
|||
|
|
|
|||
|
|
ВЕСА ГОЛОСОВ:
|
|||
|
|
- Юнона = 2 голоса (JUNONA_VOTE_WEIGHT)
|
|||
|
|
- Свидетель = 1 голос (WITNESS_VOTE_WEIGHT)
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
user_id: ID голосующего
|
|||
|
|
approve: True = за завершение, False = против
|
|||
|
|
is_junona: True если голосует Юнона
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
(success, message)
|
|||
|
|
"""
|
|||
|
|
if self.status != ContractStatus.COMPLETION_VOTING:
|
|||
|
|
return False, "Голосование за завершение не активно"
|
|||
|
|
|
|||
|
|
# Юнона голосует отдельно
|
|||
|
|
if is_junona:
|
|||
|
|
if self.junona_completion_approved is not None:
|
|||
|
|
return False, "Юнона уже проголосовала"
|
|||
|
|
self.junona_completion_approved = approve
|
|||
|
|
weight = JUNONA_VOTE_WEIGHT
|
|||
|
|
voter = "Юнона"
|
|||
|
|
else:
|
|||
|
|
# Только участники комнаты могут голосовать
|
|||
|
|
if user_id not in self.witnesses:
|
|||
|
|
return False, "Только участники комнаты могут голосовать"
|
|||
|
|
|
|||
|
|
# Убираем из предыдущего списка если передумал
|
|||
|
|
if user_id in self.completion_votes_approve:
|
|||
|
|
self.completion_votes_approve.remove(user_id)
|
|||
|
|
if user_id in self.completion_votes_reject:
|
|||
|
|
self.completion_votes_reject.remove(user_id)
|
|||
|
|
|
|||
|
|
weight = WITNESS_VOTE_WEIGHT
|
|||
|
|
voter = user_id
|
|||
|
|
|
|||
|
|
# Добавляем голос
|
|||
|
|
if approve:
|
|||
|
|
if not is_junona:
|
|||
|
|
self.completion_votes_approve.append(user_id)
|
|||
|
|
else:
|
|||
|
|
if not is_junona:
|
|||
|
|
self.completion_votes_reject.append(user_id)
|
|||
|
|
|
|||
|
|
return True, f"Голос принят ({voter}, вес: {weight})"
|
|||
|
|
|
|||
|
|
def get_completion_quorum_status(self) -> Dict:
|
|||
|
|
"""Статус кворума для завершения
|
|||
|
|
|
|||
|
|
ВЕСА:
|
|||
|
|
- Юнона = 2 голоса
|
|||
|
|
- Свидетель = 1 голос
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
{
|
|||
|
|
"total_weight": общий вес всех голосов,
|
|||
|
|
"approve_weight": вес за исполнение,
|
|||
|
|
"reject_weight": вес против,
|
|||
|
|
"has_quorum": достигнут ли кворум (>50% за),
|
|||
|
|
"junona_voted": проголосовала ли Юнона,
|
|||
|
|
"junona_approved": одобрила ли Юнона,
|
|||
|
|
"ready_to_complete": можно ли завершить
|
|||
|
|
}
|
|||
|
|
"""
|
|||
|
|
# Считаем веса
|
|||
|
|
witness_approve = len(self.completion_votes_approve) * WITNESS_VOTE_WEIGHT
|
|||
|
|
witness_reject = len(self.completion_votes_reject) * WITNESS_VOTE_WEIGHT
|
|||
|
|
|
|||
|
|
# Юнона
|
|||
|
|
junona_weight = 0
|
|||
|
|
junona_voted = self.junona_completion_approved is not None
|
|||
|
|
if junona_voted:
|
|||
|
|
if self.junona_completion_approved:
|
|||
|
|
junona_weight = JUNONA_VOTE_WEIGHT
|
|||
|
|
else:
|
|||
|
|
junona_weight = -JUNONA_VOTE_WEIGHT # Против
|
|||
|
|
|
|||
|
|
# Общий вес
|
|||
|
|
total_witnesses = len(self.witnesses) if self.witnesses else 1
|
|||
|
|
total_weight = total_witnesses * WITNESS_VOTE_WEIGHT + JUNONA_VOTE_WEIGHT
|
|||
|
|
|
|||
|
|
approve_weight = witness_approve + (JUNONA_VOTE_WEIGHT if self.junona_completion_approved else 0)
|
|||
|
|
reject_weight = witness_reject + (JUNONA_VOTE_WEIGHT if self.junona_completion_approved is False else 0)
|
|||
|
|
|
|||
|
|
# Кворум = большинство (>50%) за
|
|||
|
|
has_quorum = approve_weight > total_weight / 2
|
|||
|
|
|
|||
|
|
# Готово к завершению: кворум + Юнона одобрила
|
|||
|
|
ready_to_complete = has_quorum and self.junona_completion_approved is True
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
"total_weight": total_weight,
|
|||
|
|
"approve_weight": approve_weight,
|
|||
|
|
"reject_weight": reject_weight,
|
|||
|
|
"witnesses_voted": len(self.completion_votes_approve) + len(self.completion_votes_reject),
|
|||
|
|
"total_witnesses": total_witnesses,
|
|||
|
|
"has_quorum": has_quorum,
|
|||
|
|
"junona_voted": junona_voted,
|
|||
|
|
"junona_approved": self.junona_completion_approved,
|
|||
|
|
"ready_to_complete": ready_to_complete
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def junona_completion_decision(self, approve: bool, reason: str = None) -> Tuple[bool, str]:
|
|||
|
|
"""Юнона принимает финальное решение по завершению
|
|||
|
|
|
|||
|
|
ПОСЛЕДНЕЕ СЛОВО: Даже при кворуме свидетелей, Юнона может отклонить.
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
(success, message)
|
|||
|
|
"""
|
|||
|
|
if self.status != ContractStatus.COMPLETION_VOTING:
|
|||
|
|
return False, "Контракт не в статусе голосования за завершение"
|
|||
|
|
|
|||
|
|
self.junona_completion_approved = approve
|
|||
|
|
self.junona_completion_reason = reason
|
|||
|
|
|
|||
|
|
quorum = self.get_completion_quorum_status()
|
|||
|
|
|
|||
|
|
if approve and quorum["has_quorum"]:
|
|||
|
|
self.status = ContractStatus.COMPLETED
|
|||
|
|
return True, "Контракт исполнен! Свидетели подтвердили, Юнона одобрила."
|
|||
|
|
elif not approve:
|
|||
|
|
return True, f"Юнона не подтвердила завершение: {reason or 'требуются дополнительные доказательства'}"
|
|||
|
|
else:
|
|||
|
|
return True, f"Юнона одобрила, но нет кворума свидетелей ({quorum['approve_weight']}/{quorum['total_weight']})"
|
|||
|
|
|
|||
|
|
@classmethod
|
|||
|
|
def from_dict(cls, data: Dict) -> "Contract":
|
|||
|
|
# Парсим JSON поля
|
|||
|
|
def parse_json_list(val):
|
|||
|
|
if not val:
|
|||
|
|
return []
|
|||
|
|
if isinstance(val, list):
|
|||
|
|
return val
|
|||
|
|
try:
|
|||
|
|
return json.loads(val)
|
|||
|
|
except (json.JSONDecodeError, TypeError):
|
|||
|
|
return []
|
|||
|
|
|
|||
|
|
return cls(
|
|||
|
|
contract_id=data["contract_id"],
|
|||
|
|
creator=ContractParty(
|
|||
|
|
user_id=data["creator_id"],
|
|||
|
|
username=data.get("creator_username")
|
|||
|
|
),
|
|||
|
|
target=ContractParty(
|
|||
|
|
user_id=data["target_id"],
|
|||
|
|
username=data.get("target_username")
|
|||
|
|
),
|
|||
|
|
amount=data["amount"],
|
|||
|
|
description=data.get("description", ""),
|
|||
|
|
status=ContractStatus(data.get("status", "draft")),
|
|||
|
|
chat_id=data.get("chat_id"),
|
|||
|
|
witnesses=parse_json_list(data.get("witnesses")),
|
|||
|
|
votes_approve=parse_json_list(data.get("votes_approve")),
|
|||
|
|
votes_reject=parse_json_list(data.get("votes_reject")),
|
|||
|
|
junona_approved=data.get("junona_approved"),
|
|||
|
|
junona_reason=data.get("junona_reason"),
|
|||
|
|
# Голосование за завершение
|
|||
|
|
completion_votes_approve=parse_json_list(data.get("completion_votes_approve")),
|
|||
|
|
completion_votes_reject=parse_json_list(data.get("completion_votes_reject")),
|
|||
|
|
junona_completion_approved=data.get("junona_completion_approved"),
|
|||
|
|
junona_completion_reason=data.get("junona_completion_reason"),
|
|||
|
|
created_at=data.get("created_at"),
|
|||
|
|
accepted_at=data.get("accepted_at"),
|
|||
|
|
completed_at=data.get("completed_at"),
|
|||
|
|
cancelled_at=data.get("cancelled_at"),
|
|||
|
|
escrow_tx_id=data.get("escrow_tx_id")
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# ГЕНЕРАЦИЯ ID
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
def generate_contract_id(prefix: str = "CONTRACT") -> str:
|
|||
|
|
"""
|
|||
|
|
Генерирует уникальный ID контракта
|
|||
|
|
|
|||
|
|
Формат: PREFIX-XXXX (например PIZZA-A7F3)
|
|||
|
|
"""
|
|||
|
|
suffix = secrets.token_hex(2).upper()
|
|||
|
|
return f"{prefix}-{suffix}"
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# ФОРМАТИРОВАНИЕ
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
def format_contract_card(contract: Contract, lang: str = "ru") -> str:
|
|||
|
|
"""
|
|||
|
|
Форматирует карточку контракта для отображения в чате
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
contract: Объект контракта
|
|||
|
|
lang: Язык (ru/en/zh)
|
|||
|
|
"""
|
|||
|
|
status_emoji = {
|
|||
|
|
ContractStatus.DRAFT: "📝",
|
|||
|
|
ContractStatus.PENDING: "⏳",
|
|||
|
|
ContractStatus.ACCEPTED: "✅",
|
|||
|
|
ContractStatus.COMPLETION_VOTING: "🗳️",
|
|||
|
|
ContractStatus.COMPLETED: "🎉",
|
|||
|
|
ContractStatus.CANCELLED: "❌",
|
|||
|
|
ContractStatus.REJECTED: "🚫"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
status_text_map = {
|
|||
|
|
"ru": {
|
|||
|
|
ContractStatus.DRAFT: "ГОЛОСОВАНИЕ",
|
|||
|
|
ContractStatus.PENDING: "ОЖИДАЕТ ПОДТВЕРЖДЕНИЯ",
|
|||
|
|
ContractStatus.ACCEPTED: "ПРИНЯТ",
|
|||
|
|
ContractStatus.COMPLETION_VOTING: "ГОЛОСОВАНИЕ ЗА ЗАВЕРШЕНИЕ",
|
|||
|
|
ContractStatus.COMPLETED: "ИСПОЛНЕН",
|
|||
|
|
ContractStatus.CANCELLED: "ОТМЕНЁН",
|
|||
|
|
ContractStatus.REJECTED: "ОТКЛОНЁН"
|
|||
|
|
},
|
|||
|
|
"en": {
|
|||
|
|
ContractStatus.DRAFT: "VOTING",
|
|||
|
|
ContractStatus.PENDING: "PENDING CONFIRMATION",
|
|||
|
|
ContractStatus.ACCEPTED: "ACCEPTED",
|
|||
|
|
ContractStatus.COMPLETION_VOTING: "COMPLETION VOTING",
|
|||
|
|
ContractStatus.COMPLETED: "COMPLETED",
|
|||
|
|
ContractStatus.CANCELLED: "CANCELLED",
|
|||
|
|
ContractStatus.REJECTED: "REJECTED"
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
status_text = status_text_map.get(lang, status_text_map["ru"]).get(
|
|||
|
|
contract.status, contract.status.value.upper()
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Блок участников и голосования (создание)
|
|||
|
|
participants_str = ""
|
|||
|
|
if contract.witnesses:
|
|||
|
|
total = len(contract.witnesses)
|
|||
|
|
approve = len(contract.votes_approve)
|
|||
|
|
reject = len(contract.votes_reject)
|
|||
|
|
participants_str = f"\n║ Участники: {total} чел."
|
|||
|
|
|
|||
|
|
# Голосование за создание (если в DRAFT)
|
|||
|
|
if contract.status == ContractStatus.DRAFT:
|
|||
|
|
participants_str += f"\n║ Голоса: ✅ {approve} / ❌ {reject}"
|
|||
|
|
quorum = contract.get_quorum_status()
|
|||
|
|
if quorum["has_quorum"]:
|
|||
|
|
participants_str += " (КВОРУМ ✓)"
|
|||
|
|
else:
|
|||
|
|
need = total // 2 + 1
|
|||
|
|
participants_str += f" (нужно {need})"
|
|||
|
|
|
|||
|
|
# Блок голосования за завершение (если COMPLETION_VOTING)
|
|||
|
|
completion_str = ""
|
|||
|
|
if contract.status == ContractStatus.COMPLETION_VOTING:
|
|||
|
|
quorum = contract.get_completion_quorum_status()
|
|||
|
|
completion_str = f"\n║ ─── ГОЛОСОВАНИЕ ЗА ЗАВЕРШЕНИЕ ───"
|
|||
|
|
completion_str += f"\n║ Голоса: ✅ {quorum['approve_weight']} / ❌ {quorum['reject_weight']} (всего: {quorum['total_weight']})"
|
|||
|
|
completion_str += f"\n║ Свидетелей: {quorum['witnesses_voted']}/{quorum['total_witnesses']}"
|
|||
|
|
if quorum['junona_voted']:
|
|||
|
|
junona_vote = "✅" if quorum['junona_approved'] else "❌"
|
|||
|
|
completion_str += f"\n║ Юнона (2 голоса): {junona_vote}"
|
|||
|
|
else:
|
|||
|
|
completion_str += f"\n║ Юнона: ожидает..."
|
|||
|
|
if quorum['ready_to_complete']:
|
|||
|
|
completion_str += f"\n║ 🎯 ГОТОВО К ЗАВЕРШЕНИЮ!"
|
|||
|
|
|
|||
|
|
# Решение Юноны (создание)
|
|||
|
|
junona_str = ""
|
|||
|
|
if contract.junona_approved is not None and contract.status not in [ContractStatus.COMPLETION_VOTING, ContractStatus.COMPLETED]:
|
|||
|
|
if contract.junona_approved:
|
|||
|
|
junona_str = "\n║ 🤖 Юнона: ОДОБРЕНО"
|
|||
|
|
else:
|
|||
|
|
reason = contract.junona_reason or "условия невыполнимы"
|
|||
|
|
junona_str = f"\n║ 🤖 Юнона: ОТКЛОНЕНО ({reason[:30]})"
|
|||
|
|
|
|||
|
|
card = f"""
|
|||
|
|
╔═══════════════════════════════════════════════════╗
|
|||
|
|
║ КОНТРАКТ MONTANA #{contract.contract_id}
|
|||
|
|
╠═══════════════════════════════════════════════════╣
|
|||
|
|
║ Сторона А: {contract.creator.display_name()}
|
|||
|
|
║ Сторона Б: {contract.target.display_name()}
|
|||
|
|
║ Сумма: {contract.amount:,} Ɉ
|
|||
|
|
║ Условие: {contract.description[:40]}{'...' if len(contract.description) > 40 else ''}
|
|||
|
|
╠═══════════════════════════════════════════════════╣{participants_str}{completion_str}{junona_str}
|
|||
|
|
║ Статус: {status_emoji.get(contract.status, '❓')} {status_text}
|
|||
|
|
╚═══════════════════════════════════════════════════╝
|
|||
|
|
"""
|
|||
|
|
return card.strip()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def format_contract_mini(contract: Contract) -> str:
|
|||
|
|
"""Короткий формат для списков"""
|
|||
|
|
status_emoji = {
|
|||
|
|
ContractStatus.DRAFT: "📝",
|
|||
|
|
ContractStatus.PENDING: "⏳",
|
|||
|
|
ContractStatus.ACCEPTED: "✅",
|
|||
|
|
ContractStatus.COMPLETION_VOTING: "🗳️",
|
|||
|
|
ContractStatus.COMPLETED: "🎉",
|
|||
|
|
ContractStatus.CANCELLED: "❌",
|
|||
|
|
ContractStatus.REJECTED: "🚫"
|
|||
|
|
}
|
|||
|
|
return f"{status_emoji.get(contract.status, '❓')} #{contract.contract_id}: {contract.amount:,} Ɉ → {contract.target.display_name()}"
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# ВАЛИДАЦИЯ
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class ValidationResult:
|
|||
|
|
"""Результат валидации"""
|
|||
|
|
valid: bool
|
|||
|
|
error: Optional[str] = None
|
|||
|
|
error_code: Optional[str] = None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def validate_contract_creation(
|
|||
|
|
creator_id: str,
|
|||
|
|
target_id: str,
|
|||
|
|
amount: int,
|
|||
|
|
creator_balance: int,
|
|||
|
|
description: str = None
|
|||
|
|
) -> ValidationResult:
|
|||
|
|
"""
|
|||
|
|
Валидирует создание контракта
|
|||
|
|
|
|||
|
|
Проверяет:
|
|||
|
|
- Получатель найден
|
|||
|
|
- Сумма > 0
|
|||
|
|
- Баланс >= суммы
|
|||
|
|
- Описание не пустое
|
|||
|
|
"""
|
|||
|
|
# Проверка получателя
|
|||
|
|
if not target_id:
|
|||
|
|
return ValidationResult(
|
|||
|
|
valid=False,
|
|||
|
|
error="Получатель не указан",
|
|||
|
|
error_code="NO_TARGET"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Нельзя отправлять себе
|
|||
|
|
if str(creator_id) == str(target_id):
|
|||
|
|
return ValidationResult(
|
|||
|
|
valid=False,
|
|||
|
|
error="Нельзя создать контракт с самим собой",
|
|||
|
|
error_code="SELF_CONTRACT"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Проверка суммы
|
|||
|
|
if amount <= 0:
|
|||
|
|
return ValidationResult(
|
|||
|
|
valid=False,
|
|||
|
|
error="Сумма должна быть больше 0",
|
|||
|
|
error_code="INVALID_AMOUNT"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Проверка баланса
|
|||
|
|
if creator_balance < amount:
|
|||
|
|
return ValidationResult(
|
|||
|
|
valid=False,
|
|||
|
|
error=f"Недостаточно средств: {creator_balance} Ɉ < {amount} Ɉ",
|
|||
|
|
error_code="INSUFFICIENT_BALANCE"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Проверка описания
|
|||
|
|
if not description or len(description.strip()) < 3:
|
|||
|
|
return ValidationResult(
|
|||
|
|
valid=False,
|
|||
|
|
error="Укажите условие контракта (минимум 3 символа)",
|
|||
|
|
error_code="NO_DESCRIPTION"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
return ValidationResult(valid=True)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# ЮНОНА — РЕГУЛЯТОР ДОГОВОРЁННОСТЕЙ
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class ExecutabilityCheck:
|
|||
|
|
"""Результат проверки исполнимости"""
|
|||
|
|
executable: bool
|
|||
|
|
confidence: float # 0.0 - 1.0
|
|||
|
|
concerns: List[str] = field(default_factory=list)
|
|||
|
|
suggestions: List[str] = field(default_factory=list)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def check_executability(description: str) -> ExecutabilityCheck:
|
|||
|
|
"""
|
|||
|
|
Юнона проверяет исполнимость условия контракта
|
|||
|
|
|
|||
|
|
Проверяет:
|
|||
|
|
- Конкретность (не абстрактно ли условие?)
|
|||
|
|
- Верифицируемость (как понять что выполнено?)
|
|||
|
|
- Временные рамки (когда должно быть выполнено?)
|
|||
|
|
- Реалистичность (возможно ли это физически?)
|
|||
|
|
|
|||
|
|
Возвращает оценку и рекомендации
|
|||
|
|
"""
|
|||
|
|
concerns = []
|
|||
|
|
suggestions = []
|
|||
|
|
confidence = 1.0
|
|||
|
|
|
|||
|
|
desc_lower = description.lower()
|
|||
|
|
|
|||
|
|
# Слишком короткое описание
|
|||
|
|
if len(description) < 10:
|
|||
|
|
confidence -= 0.3
|
|||
|
|
concerns.append("Условие слишком краткое")
|
|||
|
|
suggestions.append("Опиши подробнее что именно должно быть сделано")
|
|||
|
|
|
|||
|
|
# Абстрактные формулировки
|
|||
|
|
abstract_words = ["помощь", "поддержка", "содействие", "что-то", "как-нибудь"]
|
|||
|
|
for word in abstract_words:
|
|||
|
|
if word in desc_lower:
|
|||
|
|
confidence -= 0.2
|
|||
|
|
concerns.append(f"Слово '{word}' слишком абстрактно")
|
|||
|
|
suggestions.append("Укажи конкретный результат")
|
|||
|
|
|
|||
|
|
# Нет временных рамок
|
|||
|
|
time_words = ["сегодня", "завтра", "до", "через", "в течение", "час", "день", "неделя"]
|
|||
|
|
has_time = any(word in desc_lower for word in time_words)
|
|||
|
|
if not has_time:
|
|||
|
|
confidence -= 0.1
|
|||
|
|
concerns.append("Не указаны сроки")
|
|||
|
|
suggestions.append("Добавь когда должно быть выполнено")
|
|||
|
|
|
|||
|
|
# Как верифицировать?
|
|||
|
|
verify_words = ["фото", "скриншот", "чек", "видео", "отчёт", "доказательство"]
|
|||
|
|
has_verification = any(word in desc_lower for word in verify_words)
|
|||
|
|
if not has_verification and confidence > 0.5:
|
|||
|
|
suggestions.append("Как я пойму что условие выполнено? Добавь способ подтверждения")
|
|||
|
|
|
|||
|
|
# Ограничиваем confidence
|
|||
|
|
confidence = max(0.1, min(1.0, confidence))
|
|||
|
|
|
|||
|
|
return ExecutabilityCheck(
|
|||
|
|
executable=confidence >= 0.5,
|
|||
|
|
confidence=confidence,
|
|||
|
|
concerns=concerns,
|
|||
|
|
suggestions=suggestions
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class GroupContractRoom:
|
|||
|
|
"""
|
|||
|
|
Контрактная комната (группа)
|
|||
|
|
|
|||
|
|
Юнона управляет всеми договорённостями в группе.
|
|||
|
|
До 12 участников могут быть сторонами контрактов.
|
|||
|
|
Все участники = потенциальные стороны контракта.
|
|||
|
|
Большинство должно одобрить + Юнона принимает финальное решение.
|
|||
|
|
"""
|
|||
|
|
chat_id: str
|
|||
|
|
participants: List[str] = field(default_factory=list) # До 12 участников
|
|||
|
|
active_contracts: List[str] = field(default_factory=list) # ID активных контрактов
|
|||
|
|
total_volume: int = 0 # Общий объём сделок в Ɉ
|
|||
|
|
_verified_members: List[str] = field(default_factory=list) # Верифицированные через Telegram API
|
|||
|
|
|
|||
|
|
MAX_PARTICIPANTS = 12
|
|||
|
|
|
|||
|
|
def add_participant(self, user_id: str, verified: bool = False) -> bool:
|
|||
|
|
"""Добавить участника
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
user_id: ID пользователя
|
|||
|
|
verified: True если подтверждён через Telegram API (реальный участник группы)
|
|||
|
|
"""
|
|||
|
|
if len(self.participants) >= self.MAX_PARTICIPANTS:
|
|||
|
|
return False
|
|||
|
|
if user_id not in self.participants:
|
|||
|
|
self.participants.append(user_id)
|
|||
|
|
if verified:
|
|||
|
|
self._verified_members.append(user_id)
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
def is_verified_member(self, user_id: str) -> bool:
|
|||
|
|
"""Проверяет верифицирован ли участник (через Telegram API)"""
|
|||
|
|
return user_id in self._verified_members
|
|||
|
|
|
|||
|
|
def validate_witnesses(self, witness_ids: List[str]) -> Tuple[bool, str, List[str]]:
|
|||
|
|
"""
|
|||
|
|
Валидирует список свидетелей
|
|||
|
|
|
|||
|
|
DISNEY-SAFE: Нельзя указать произвольные ID как свидетелей.
|
|||
|
|
Только верифицированные участники группы.
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
(valid, error_message, valid_witnesses)
|
|||
|
|
"""
|
|||
|
|
valid_witnesses = []
|
|||
|
|
invalid = []
|
|||
|
|
|
|||
|
|
for wid in witness_ids:
|
|||
|
|
if wid in self.participants:
|
|||
|
|
valid_witnesses.append(wid)
|
|||
|
|
else:
|
|||
|
|
invalid.append(wid)
|
|||
|
|
|
|||
|
|
if invalid:
|
|||
|
|
return False, f"Участники {invalid} не в группе", valid_witnesses
|
|||
|
|
|
|||
|
|
return True, "OK", valid_witnesses
|
|||
|
|
|
|||
|
|
def can_create_contract(self, creator_id: str, target_id: str) -> Tuple[bool, str]:
|
|||
|
|
"""Проверяет можно ли создать контракт между участниками"""
|
|||
|
|
if creator_id not in self.participants:
|
|||
|
|
return False, f"Пользователь {creator_id} не участник комнаты"
|
|||
|
|
if target_id not in self.participants:
|
|||
|
|
return False, f"Пользователь {target_id} не участник комнаты"
|
|||
|
|
return True, "OK"
|
|||
|
|
|
|||
|
|
def get_witnesses(self, exclude: List[str] = None) -> List[str]:
|
|||
|
|
"""Возвращает всех участников кроме исключённых (для голосования)"""
|
|||
|
|
exclude = exclude or []
|
|||
|
|
return [p for p in self.participants if p not in exclude]
|
|||
|
|
|
|||
|
|
def get_quorum_threshold(self) -> int:
|
|||
|
|
"""Порог кворума: >50% участников должны одобрить"""
|
|||
|
|
return len(self.participants) // 2 + 1
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# ДИАЛОГ КОНТРАКТА
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
class ContractDialogState(Enum):
|
|||
|
|
"""Состояния диалога создания контракта"""
|
|||
|
|
IDLE = "idle"
|
|||
|
|
WAITING_TARGET = "waiting_target"
|
|||
|
|
WAITING_AMOUNT = "waiting_amount"
|
|||
|
|
WAITING_DESCRIPTION = "waiting_description"
|
|||
|
|
WAITING_CONFIRMATION = "waiting_confirmation"
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class ContractDialog:
|
|||
|
|
"""Диалог создания контракта"""
|
|||
|
|
creator_id: str
|
|||
|
|
chat_id: Optional[str] = None
|
|||
|
|
state: ContractDialogState = ContractDialogState.IDLE
|
|||
|
|
target_id: Optional[str] = None
|
|||
|
|
target_username: Optional[str] = None
|
|||
|
|
amount: Optional[int] = None
|
|||
|
|
description: Optional[str] = None
|
|||
|
|
witnesses: List[str] = field(default_factory=list)
|
|||
|
|
|
|||
|
|
def next_prompt(self, lang: str = "ru") -> str:
|
|||
|
|
"""Возвращает следующий вопрос для диалога"""
|
|||
|
|
prompts = {
|
|||
|
|
ContractDialogState.WAITING_TARGET: "Кто будет второй стороной? Укажи @username, ID или имя из контактов",
|
|||
|
|
ContractDialogState.WAITING_AMOUNT: "Какую сумму хочешь заморозить в контракте? (в Ɉ)",
|
|||
|
|
ContractDialogState.WAITING_DESCRIPTION: "Что получишь взамен? Опиши условие контракта",
|
|||
|
|
ContractDialogState.WAITING_CONFIRMATION: "Проверь контракт. Всё верно?"
|
|||
|
|
}
|
|||
|
|
return prompts.get(self.state, "")
|
|||
|
|
|
|||
|
|
def is_complete(self) -> bool:
|
|||
|
|
"""Проверяет готовность к созданию контракта"""
|
|||
|
|
return all([
|
|||
|
|
self.target_id,
|
|||
|
|
self.amount and self.amount > 0,
|
|||
|
|
self.description
|
|||
|
|
])
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# РЕЗОЛВЕР ПОЛУЧАТЕЛЯ
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
def resolve_target(
|
|||
|
|
input_str: str,
|
|||
|
|
contacts: List[Dict] = None,
|
|||
|
|
reply_user_id: str = None
|
|||
|
|
) -> Tuple[Optional[str], Optional[str], str]:
|
|||
|
|
"""
|
|||
|
|
Резолвит получателя из разных форматов
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
input_str: Ввод пользователя (@username, ID, имя)
|
|||
|
|
contacts: Адресная книга пользователя
|
|||
|
|
reply_user_id: ID из reply на сообщение
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
(user_id, username, method) или (None, None, error)
|
|||
|
|
"""
|
|||
|
|
input_str = input_str.strip()
|
|||
|
|
|
|||
|
|
# 1. Reply на сообщение
|
|||
|
|
if reply_user_id:
|
|||
|
|
return reply_user_id, None, "reply"
|
|||
|
|
|
|||
|
|
# 2. @username
|
|||
|
|
if input_str.startswith("@"):
|
|||
|
|
username = input_str[1:]
|
|||
|
|
# В реальности нужно резолвить через Telegram API
|
|||
|
|
return None, username, "username"
|
|||
|
|
|
|||
|
|
# 3. Числовой ID
|
|||
|
|
if input_str.isdigit():
|
|||
|
|
return input_str, None, "id"
|
|||
|
|
|
|||
|
|
# 4. Поиск в адресной книге
|
|||
|
|
if contacts:
|
|||
|
|
for contact in contacts:
|
|||
|
|
if contact.get("name", "").lower() == input_str.lower():
|
|||
|
|
return contact["target_id"], contact.get("target_username"), "contacts"
|
|||
|
|
|
|||
|
|
return None, None, f"Не удалось найти '{input_str}'. Укажи @username или ID"
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# УТИЛИТЫ
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
def now_iso() -> str:
|
|||
|
|
"""Текущее время в ISO формате"""
|
|||
|
|
return datetime.now(timezone.utc).isoformat()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def parse_amount(text: str) -> Optional[int]:
|
|||
|
|
"""Парсит сумму из текста"""
|
|||
|
|
# Убираем пробелы, запятые, символ валюты
|
|||
|
|
cleaned = text.replace(" ", "").replace(",", "").replace("Ɉ", "").strip()
|
|||
|
|
try:
|
|||
|
|
amount = int(cleaned)
|
|||
|
|
return amount if amount > 0 else None
|
|||
|
|
except ValueError:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# ПРИМЕРЫ ИСПОЛЬЗОВАНИЯ
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
# Пример создания контракта
|
|||
|
|
contract = Contract(
|
|||
|
|
contract_id=generate_contract_id("PIZZA"),
|
|||
|
|
creator=ContractParty(user_id="8552053404", username="financier"),
|
|||
|
|
target=ContractParty(user_id="987654321", username="pizza_place"),
|
|||
|
|
amount=10000,
|
|||
|
|
description="Большая пицца пепперони за консультацию",
|
|||
|
|
chat_id="-1001234567890",
|
|||
|
|
witnesses=["111", "222", "333"],
|
|||
|
|
created_at=now_iso()
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
print(format_contract_card(contract))
|
|||
|
|
print()
|
|||
|
|
print("Mini:", format_contract_mini(contract))
|
|||
|
|
print()
|
|||
|
|
print("Dict:", json.dumps(contract.to_dict(), indent=2, ensure_ascii=False))
|