805 lines
32 KiB
Python
805 lines
32 KiB
Python
|
|
#!/usr/bin/env python3
|
|||
|
|
"""
|
|||
|
|
Montana Protocol — Ascending Auction System
|
|||
|
|
Аукцион нарастания для всех сервисов Montana.
|
|||
|
|
|
|||
|
|
Модель ценообразования:
|
|||
|
|
- 1-й сервис: 1 Ɉ
|
|||
|
|
- 2-й сервис: 2 Ɉ
|
|||
|
|
- 3-й сервис: 3 Ɉ
|
|||
|
|
- N-й сервис: N Ɉ (шаг 1 Ɉ)
|
|||
|
|
|
|||
|
|
Применяется к:
|
|||
|
|
- Domains (alice@efir.org)
|
|||
|
|
- VPN subscriptions
|
|||
|
|
- Всем будущим сервисам
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import json
|
|||
|
|
import threading
|
|||
|
|
import fcntl
|
|||
|
|
import time
|
|||
|
|
from pathlib import Path
|
|||
|
|
from typing import Dict, Optional, List
|
|||
|
|
from datetime import datetime
|
|||
|
|
import logging
|
|||
|
|
|
|||
|
|
log = logging.getLogger("montana_auction")
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# AUCTION SERVICE TYPES
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
class ServiceType:
|
|||
|
|
"""Типы сервисов Montana Protocol"""
|
|||
|
|
# Базовые сервисы
|
|||
|
|
DOMAIN = "domain" # alice@efir.org
|
|||
|
|
VPN = "vpn" # WireGuard VPN subscription
|
|||
|
|
STORAGE = "storage" # Future: distributed storage
|
|||
|
|
COMPUTE = "compute" # Future: compute credits
|
|||
|
|
|
|||
|
|
# Телефония и коммуникации
|
|||
|
|
PHONE_NUMBER = "phone_number" # Виртуальный номер +montana-123456
|
|||
|
|
AUDIO_SECOND = "audio_second" # Цена 1 секунды аудио звонка
|
|||
|
|
VIDEO_SECOND = "video_second" # Цена 1 секунды видео звонка
|
|||
|
|
|
|||
|
|
@classmethod
|
|||
|
|
def all(cls) -> List[str]:
|
|||
|
|
return [
|
|||
|
|
cls.DOMAIN,
|
|||
|
|
cls.VPN,
|
|||
|
|
cls.STORAGE,
|
|||
|
|
cls.COMPUTE,
|
|||
|
|
cls.PHONE_NUMBER,
|
|||
|
|
cls.AUDIO_SECOND,
|
|||
|
|
cls.VIDEO_SECOND
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
@classmethod
|
|||
|
|
def communication_services(cls) -> List[str]:
|
|||
|
|
"""Коммуникационные сервисы (телефония, звонки)"""
|
|||
|
|
return [cls.PHONE_NUMBER, cls.AUDIO_SECOND, cls.VIDEO_SECOND]
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# AUCTION COUNTER REGISTRY
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
class AuctionRegistry:
|
|||
|
|
"""
|
|||
|
|
Реестр счетчиков аукциона для каждого типа сервиса.
|
|||
|
|
Консенсус между 3 узлами Montana Network.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def __init__(self, data_dir: Path):
|
|||
|
|
self.data_dir = data_dir
|
|||
|
|
self.data_dir.mkdir(parents=True, exist_ok=True)
|
|||
|
|
self.auction_file = self.data_dir / "auction_counters.json"
|
|||
|
|
self.purchases_file = self.data_dir / "auction_purchases.json"
|
|||
|
|
self._lock = threading.Lock()
|
|||
|
|
self._ensure_files()
|
|||
|
|
|
|||
|
|
def _ensure_files(self):
|
|||
|
|
"""Создать файлы аукциона если не существуют"""
|
|||
|
|
if not self.auction_file.exists():
|
|||
|
|
self._save_counters({service: 0 for service in ServiceType.all()})
|
|||
|
|
if not self.purchases_file.exists():
|
|||
|
|
self._save_purchases([])
|
|||
|
|
|
|||
|
|
def _load_counters(self) -> Dict[str, int]:
|
|||
|
|
"""Загрузить счетчики аукциона (thread-safe)"""
|
|||
|
|
with open(self.auction_file, 'r') as f:
|
|||
|
|
fcntl.flock(f.fileno(), fcntl.LOCK_SH)
|
|||
|
|
try:
|
|||
|
|
data = json.load(f)
|
|||
|
|
finally:
|
|||
|
|
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
|||
|
|
return data
|
|||
|
|
|
|||
|
|
def _save_counters(self, counters: Dict[str, int]):
|
|||
|
|
"""Сохранить счетчики аукциона (thread-safe)"""
|
|||
|
|
with open(self.auction_file, 'w') as f:
|
|||
|
|
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
|||
|
|
try:
|
|||
|
|
json.dump(counters, f, indent=2)
|
|||
|
|
finally:
|
|||
|
|
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
|||
|
|
|
|||
|
|
def _load_purchases(self) -> List[Dict]:
|
|||
|
|
"""Загрузить историю покупок"""
|
|||
|
|
with open(self.purchases_file, 'r') as f:
|
|||
|
|
fcntl.flock(f.fileno(), fcntl.LOCK_SH)
|
|||
|
|
try:
|
|||
|
|
data = json.load(f)
|
|||
|
|
finally:
|
|||
|
|
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
|||
|
|
return data
|
|||
|
|
|
|||
|
|
def _save_purchases(self, purchases: List[Dict]):
|
|||
|
|
"""Сохранить историю покупок"""
|
|||
|
|
with open(self.purchases_file, 'w') as f:
|
|||
|
|
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
|||
|
|
try:
|
|||
|
|
json.dump(purchases, f, indent=2)
|
|||
|
|
finally:
|
|||
|
|
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
|||
|
|
|
|||
|
|
def get_current_price(self, service_type: str) -> int:
|
|||
|
|
"""
|
|||
|
|
Получить текущую цену для следующей покупки.
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
service_type: Тип сервиса (domain, vpn, etc.)
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Цена в Ɉ (монетах времени)
|
|||
|
|
"""
|
|||
|
|
if service_type not in ServiceType.all():
|
|||
|
|
raise ValueError(f"Unknown service type: {service_type}")
|
|||
|
|
|
|||
|
|
with self._lock:
|
|||
|
|
counters = self._load_counters()
|
|||
|
|
count = counters.get(service_type, 0)
|
|||
|
|
# Следующая цена = текущий счетчик + 1
|
|||
|
|
return count + 1
|
|||
|
|
|
|||
|
|
def get_total_sold(self, service_type: str) -> int:
|
|||
|
|
"""
|
|||
|
|
Получить количество проданных сервисов.
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
service_type: Тип сервиса
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Количество проданных сервисов
|
|||
|
|
"""
|
|||
|
|
if service_type not in ServiceType.all():
|
|||
|
|
raise ValueError(f"Unknown service type: {service_type}")
|
|||
|
|
|
|||
|
|
with self._lock:
|
|||
|
|
counters = self._load_counters()
|
|||
|
|
return counters.get(service_type, 0)
|
|||
|
|
|
|||
|
|
def purchase(
|
|||
|
|
self,
|
|||
|
|
service_type: str,
|
|||
|
|
buyer_address: str,
|
|||
|
|
service_id: str,
|
|||
|
|
amount_paid: int
|
|||
|
|
) -> Dict:
|
|||
|
|
"""
|
|||
|
|
Зарегистрировать покупку сервиса.
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
service_type: Тип сервиса (domain, vpn, etc.)
|
|||
|
|
buyer_address: Montana адрес покупателя
|
|||
|
|
service_id: Идентификатор сервиса (например, "alice" для alice@efir.org)
|
|||
|
|
amount_paid: Сумма оплаченная в Ɉ
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Dict с результатом покупки
|
|||
|
|
|
|||
|
|
Raises:
|
|||
|
|
ValueError: Если цена не совпадает или недостаточно монет
|
|||
|
|
"""
|
|||
|
|
if service_type not in ServiceType.all():
|
|||
|
|
raise ValueError(f"Unknown service type: {service_type}")
|
|||
|
|
|
|||
|
|
with self._lock:
|
|||
|
|
counters = self._load_counters()
|
|||
|
|
current_count = counters.get(service_type, 0)
|
|||
|
|
expected_price = current_count + 1
|
|||
|
|
|
|||
|
|
if amount_paid < expected_price:
|
|||
|
|
raise ValueError(
|
|||
|
|
f"Insufficient payment: {amount_paid} Ɉ, expected {expected_price} Ɉ"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Инкремент счетчика
|
|||
|
|
counters[service_type] = current_count + 1
|
|||
|
|
self._save_counters(counters)
|
|||
|
|
|
|||
|
|
# Записать в историю покупок
|
|||
|
|
purchases = self._load_purchases()
|
|||
|
|
purchase = {
|
|||
|
|
"service_type": service_type,
|
|||
|
|
"service_id": service_id,
|
|||
|
|
"buyer_address": buyer_address,
|
|||
|
|
"price_paid": amount_paid,
|
|||
|
|
"purchase_number": current_count + 1,
|
|||
|
|
"timestamp": datetime.utcnow().isoformat() + "Z"
|
|||
|
|
}
|
|||
|
|
purchases.append(purchase)
|
|||
|
|
self._save_purchases(purchases)
|
|||
|
|
|
|||
|
|
log.info(
|
|||
|
|
f"Auction purchase: {service_type} #{current_count + 1} "
|
|||
|
|
f"'{service_id}' by {buyer_address[:10]}... for {amount_paid} Ɉ"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
"success": True,
|
|||
|
|
"service_type": service_type,
|
|||
|
|
"service_id": service_id,
|
|||
|
|
"price_paid": amount_paid,
|
|||
|
|
"purchase_number": current_count + 1,
|
|||
|
|
"next_price": current_count + 2
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def get_purchase_history(
|
|||
|
|
self,
|
|||
|
|
service_type: Optional[str] = None,
|
|||
|
|
limit: int = 100
|
|||
|
|
) -> List[Dict]:
|
|||
|
|
"""
|
|||
|
|
Получить историю покупок.
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
service_type: Фильтр по типу сервиса (None = все)
|
|||
|
|
limit: Максимум записей
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Список покупок (последние первыми)
|
|||
|
|
"""
|
|||
|
|
with self._lock:
|
|||
|
|
purchases = self._load_purchases()
|
|||
|
|
|
|||
|
|
if service_type:
|
|||
|
|
purchases = [p for p in purchases if p.get("service_type") == service_type]
|
|||
|
|
|
|||
|
|
# Последние первыми
|
|||
|
|
purchases.reverse()
|
|||
|
|
return purchases[:limit]
|
|||
|
|
|
|||
|
|
def get_stats(self) -> Dict:
|
|||
|
|
"""
|
|||
|
|
Получить статистику аукциона по всем сервисам.
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Dict со статистикой
|
|||
|
|
"""
|
|||
|
|
with self._lock:
|
|||
|
|
counters = self._load_counters()
|
|||
|
|
purchases = self._load_purchases()
|
|||
|
|
|
|||
|
|
total_revenue = sum(p.get("price_paid", 0) for p in purchases)
|
|||
|
|
|
|||
|
|
stats = {
|
|||
|
|
"total_services_sold": sum(counters.values()),
|
|||
|
|
"total_revenue": total_revenue,
|
|||
|
|
"services": {}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for service_type in ServiceType.all():
|
|||
|
|
count = counters.get(service_type, 0)
|
|||
|
|
next_price = count + 1
|
|||
|
|
service_purchases = [p for p in purchases if p.get("service_type") == service_type]
|
|||
|
|
revenue = sum(p.get("price_paid", 0) for p in service_purchases)
|
|||
|
|
|
|||
|
|
stats["services"][service_type] = {
|
|||
|
|
"total_sold": count,
|
|||
|
|
"next_price": next_price,
|
|||
|
|
"revenue": revenue,
|
|||
|
|
"latest_purchase": service_purchases[-1] if service_purchases else None
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return stats
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# GLOBAL INSTANCE
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
_auction_registry: Optional[AuctionRegistry] = None
|
|||
|
|
|
|||
|
|
def get_auction_registry(data_dir: Path = None) -> AuctionRegistry:
|
|||
|
|
"""Получить глобальный экземпляр AuctionRegistry (singleton)"""
|
|||
|
|
global _auction_registry
|
|||
|
|
if _auction_registry is None:
|
|||
|
|
if data_dir is None:
|
|||
|
|
data_dir = Path(__file__).parent / "data"
|
|||
|
|
_auction_registry = AuctionRegistry(data_dir)
|
|||
|
|
return _auction_registry
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# DOMAIN SERVICE (Montana Name Service)
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
class DomainService:
|
|||
|
|
"""
|
|||
|
|
Montana Name Service (MNS) — доменный слой поверх Montana Protocol.
|
|||
|
|
|
|||
|
|
Домены вида: alice@efir.org → mt1234...5678
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def __init__(self, data_dir: Path):
|
|||
|
|
self.data_dir = data_dir
|
|||
|
|
self.data_dir.mkdir(parents=True, exist_ok=True)
|
|||
|
|
self.domains_file = self.data_dir / "domains.json"
|
|||
|
|
self._lock = threading.Lock()
|
|||
|
|
self._ensure_file()
|
|||
|
|
|
|||
|
|
def _ensure_file(self):
|
|||
|
|
"""Создать файл доменов если не существует"""
|
|||
|
|
if not self.domains_file.exists():
|
|||
|
|
self._save_domains({})
|
|||
|
|
|
|||
|
|
def _load_domains(self) -> Dict[str, Dict]:
|
|||
|
|
"""Загрузить реестр доменов"""
|
|||
|
|
with open(self.domains_file, 'r') as f:
|
|||
|
|
fcntl.flock(f.fileno(), fcntl.LOCK_SH)
|
|||
|
|
try:
|
|||
|
|
data = json.load(f)
|
|||
|
|
finally:
|
|||
|
|
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
|||
|
|
return data
|
|||
|
|
|
|||
|
|
def _save_domains(self, domains: Dict[str, Dict]):
|
|||
|
|
"""Сохранить реестр доменов"""
|
|||
|
|
with open(self.domains_file, 'w') as f:
|
|||
|
|
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
|||
|
|
try:
|
|||
|
|
json.dump(domains, f, indent=2)
|
|||
|
|
finally:
|
|||
|
|
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
|||
|
|
|
|||
|
|
def is_available(self, domain: str) -> bool:
|
|||
|
|
"""Проверить доступность домена"""
|
|||
|
|
with self._lock:
|
|||
|
|
domains = self._load_domains()
|
|||
|
|
return domain.lower() not in domains
|
|||
|
|
|
|||
|
|
def register(
|
|||
|
|
self,
|
|||
|
|
domain: str,
|
|||
|
|
owner_address: str,
|
|||
|
|
price_paid: int
|
|||
|
|
) -> Dict:
|
|||
|
|
"""
|
|||
|
|
Зарегистрировать домен через аукцион.
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
domain: Имя домена (например, "alice")
|
|||
|
|
owner_address: Montana адрес владельца
|
|||
|
|
price_paid: Цена оплаченная в Ɉ
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Dict с результатом регистрации
|
|||
|
|
"""
|
|||
|
|
domain = domain.lower()
|
|||
|
|
|
|||
|
|
# Валидация домена
|
|||
|
|
if not self._validate_domain(domain):
|
|||
|
|
raise ValueError(f"Invalid domain name: {domain}")
|
|||
|
|
|
|||
|
|
with self._lock:
|
|||
|
|
domains = self._load_domains()
|
|||
|
|
|
|||
|
|
if domain in domains:
|
|||
|
|
raise ValueError(f"Domain already registered: {domain}@efir.org")
|
|||
|
|
|
|||
|
|
# Регистрация через аукцион
|
|||
|
|
auction = get_auction_registry(self.data_dir.parent / "data")
|
|||
|
|
result = auction.purchase(
|
|||
|
|
service_type=ServiceType.DOMAIN,
|
|||
|
|
buyer_address=owner_address,
|
|||
|
|
service_id=domain,
|
|||
|
|
amount_paid=price_paid
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Сохранить в реестре доменов
|
|||
|
|
domains[domain] = {
|
|||
|
|
"owner": owner_address,
|
|||
|
|
"registered": datetime.utcnow().isoformat() + "Z",
|
|||
|
|
"purchase_number": result["purchase_number"],
|
|||
|
|
"price_paid": price_paid
|
|||
|
|
}
|
|||
|
|
self._save_domains(domains)
|
|||
|
|
|
|||
|
|
log.info(f"Domain registered: {domain}@efir.org → {owner_address[:10]}...")
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
"success": True,
|
|||
|
|
"domain": f"{domain}@efir.org",
|
|||
|
|
"owner": owner_address,
|
|||
|
|
"price_paid": price_paid,
|
|||
|
|
"purchase_number": result["purchase_number"]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def lookup(self, domain: str) -> Optional[Dict]:
|
|||
|
|
"""
|
|||
|
|
Найти владельца домена.
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
domain: Имя домена (alice или alice@efir.org)
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Dict с информацией о домене или None
|
|||
|
|
"""
|
|||
|
|
# Убрать @efir.org если есть
|
|||
|
|
domain = domain.lower().replace("@efir.org", "")
|
|||
|
|
|
|||
|
|
with self._lock:
|
|||
|
|
domains = self._load_domains()
|
|||
|
|
if domain in domains:
|
|||
|
|
info = domains[domain].copy()
|
|||
|
|
info["domain"] = f"{domain}@efir.org"
|
|||
|
|
return info
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
def _validate_domain(self, domain: str) -> bool:
|
|||
|
|
"""
|
|||
|
|
Валидация имени домена.
|
|||
|
|
|
|||
|
|
Правила:
|
|||
|
|
- 3-32 символа
|
|||
|
|
- Только a-z, 0-9, дефис
|
|||
|
|
- Не начинается и не заканчивается дефисом
|
|||
|
|
"""
|
|||
|
|
if not domain:
|
|||
|
|
return False
|
|||
|
|
if len(domain) < 3 or len(domain) > 32:
|
|||
|
|
return False
|
|||
|
|
if not domain[0].isalnum() or not domain[-1].isalnum():
|
|||
|
|
return False
|
|||
|
|
if not all(c.isalnum() or c == '-' for c in domain):
|
|||
|
|
return False
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# GLOBAL DOMAIN SERVICE
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
_domain_service: Optional[DomainService] = None
|
|||
|
|
|
|||
|
|
def get_domain_service(data_dir: Path = None) -> DomainService:
|
|||
|
|
"""Получить глобальный экземпляр DomainService (singleton)"""
|
|||
|
|
global _domain_service
|
|||
|
|
if _domain_service is None:
|
|||
|
|
if data_dir is None:
|
|||
|
|
data_dir = Path(__file__).parent / "data"
|
|||
|
|
_domain_service = DomainService(data_dir)
|
|||
|
|
return _domain_service
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# PHONE SERVICE — Virtual Phone Numbers
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
class PhoneService:
|
|||
|
|
"""
|
|||
|
|
Montana Phone Service — виртуальные телефонные номера для аудио/видео звонков.
|
|||
|
|
|
|||
|
|
Формат номера: +montana-NNNNNN (где N = sequential number)
|
|||
|
|
Пример: +montana-000001, +montana-000042
|
|||
|
|
|
|||
|
|
Возможности:
|
|||
|
|
- P2P аудио звонки (WebRTC)
|
|||
|
|
- P2P видео звонки (WebRTC)
|
|||
|
|
- Оплата за секунды через аукцион
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def __init__(self, data_dir: Path):
|
|||
|
|
self.data_dir = data_dir
|
|||
|
|
self.data_dir.mkdir(parents=True, exist_ok=True)
|
|||
|
|
self.phone_file = self.data_dir / "phone_numbers.json"
|
|||
|
|
self._lock = threading.Lock()
|
|||
|
|
self._ensure_file()
|
|||
|
|
|
|||
|
|
def _ensure_file(self):
|
|||
|
|
"""Создать файл номеров если не существует"""
|
|||
|
|
if not self.phone_file.exists():
|
|||
|
|
self._save_numbers({})
|
|||
|
|
|
|||
|
|
def _load_numbers(self) -> Dict[str, Dict]:
|
|||
|
|
"""Загрузить реестр номеров"""
|
|||
|
|
with open(self.phone_file, 'r') as f:
|
|||
|
|
fcntl.flock(f.fileno(), fcntl.LOCK_SH)
|
|||
|
|
try:
|
|||
|
|
data = json.load(f)
|
|||
|
|
finally:
|
|||
|
|
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
|||
|
|
return data
|
|||
|
|
|
|||
|
|
def _save_numbers(self, numbers: Dict[str, Dict]):
|
|||
|
|
"""Сохранить реестр номеров"""
|
|||
|
|
with open(self.phone_file, 'w') as f:
|
|||
|
|
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
|||
|
|
try:
|
|||
|
|
json.dump(numbers, f, indent=2)
|
|||
|
|
finally:
|
|||
|
|
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
|||
|
|
|
|||
|
|
def format_number(self, number: int) -> str:
|
|||
|
|
"""Форматировать номер: 42 → +montana-000042"""
|
|||
|
|
return f"+montana-{number:06d}"
|
|||
|
|
|
|||
|
|
def is_available(self, number: int) -> bool:
|
|||
|
|
"""Проверить доступность номера"""
|
|||
|
|
with self._lock:
|
|||
|
|
numbers = self._load_numbers()
|
|||
|
|
key = self.format_number(number)
|
|||
|
|
return key not in numbers
|
|||
|
|
|
|||
|
|
def register(
|
|||
|
|
self,
|
|||
|
|
number: int,
|
|||
|
|
owner_address: str,
|
|||
|
|
price_paid: int
|
|||
|
|
) -> Dict:
|
|||
|
|
"""
|
|||
|
|
Зарегистрировать виртуальный номер через аукцион.
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
number: Номер (например, 42 для +montana-000042)
|
|||
|
|
owner_address: Montana адрес владельца
|
|||
|
|
price_paid: Цена оплаченная в Ɉ
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Dict с результатом регистрации
|
|||
|
|
"""
|
|||
|
|
formatted = self.format_number(number)
|
|||
|
|
|
|||
|
|
with self._lock:
|
|||
|
|
numbers = self._load_numbers()
|
|||
|
|
|
|||
|
|
if formatted in numbers:
|
|||
|
|
raise ValueError(f"Phone number already registered: {formatted}")
|
|||
|
|
|
|||
|
|
# Регистрация через аукцион
|
|||
|
|
auction = get_auction_registry(self.data_dir.parent / "data")
|
|||
|
|
result = auction.purchase(
|
|||
|
|
service_type=ServiceType.PHONE_NUMBER,
|
|||
|
|
buyer_address=owner_address,
|
|||
|
|
service_id=formatted,
|
|||
|
|
amount_paid=price_paid
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Сохранить в реестре номеров
|
|||
|
|
numbers[formatted] = {
|
|||
|
|
"owner": owner_address,
|
|||
|
|
"number": number,
|
|||
|
|
"registered": datetime.utcnow().isoformat() + "Z",
|
|||
|
|
"purchase_number": result["purchase_number"],
|
|||
|
|
"price_paid": price_paid,
|
|||
|
|
"audio_seconds_used": 0,
|
|||
|
|
"video_seconds_used": 0
|
|||
|
|
}
|
|||
|
|
self._save_numbers(numbers)
|
|||
|
|
|
|||
|
|
log.info(f"Phone number registered: {formatted} → {owner_address[:10]}...")
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
"success": True,
|
|||
|
|
"phone_number": formatted,
|
|||
|
|
"owner": owner_address,
|
|||
|
|
"price_paid": price_paid,
|
|||
|
|
"purchase_number": result["purchase_number"]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def lookup(self, number: int) -> Optional[Dict]:
|
|||
|
|
"""
|
|||
|
|
Найти владельца номера.
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
number: Номер (42 или "+montana-000042")
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Dict с информацией о номере или None
|
|||
|
|
"""
|
|||
|
|
# Поддержка обоих форматов
|
|||
|
|
if isinstance(number, str) and number.startswith("+montana-"):
|
|||
|
|
formatted = number
|
|||
|
|
else:
|
|||
|
|
formatted = self.format_number(int(number))
|
|||
|
|
|
|||
|
|
with self._lock:
|
|||
|
|
numbers = self._load_numbers()
|
|||
|
|
if formatted in numbers:
|
|||
|
|
return numbers[formatted].copy()
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
def record_call(
|
|||
|
|
self,
|
|||
|
|
phone_number: str,
|
|||
|
|
call_type: str,
|
|||
|
|
duration_seconds: int,
|
|||
|
|
price_per_second: int
|
|||
|
|
) -> Dict:
|
|||
|
|
"""
|
|||
|
|
Записать использование звонка.
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
phone_number: Номер телефона
|
|||
|
|
call_type: "audio" или "video"
|
|||
|
|
duration_seconds: Длительность в секундах
|
|||
|
|
price_per_second: Цена за секунду в Ɉ
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Dict с информацией о звонке
|
|||
|
|
"""
|
|||
|
|
total_cost = duration_seconds * price_per_second
|
|||
|
|
|
|||
|
|
with self._lock:
|
|||
|
|
numbers = self._load_numbers()
|
|||
|
|
|
|||
|
|
if phone_number not in numbers:
|
|||
|
|
raise ValueError(f"Phone number not found: {phone_number}")
|
|||
|
|
|
|||
|
|
if call_type == "audio":
|
|||
|
|
numbers[phone_number]["audio_seconds_used"] += duration_seconds
|
|||
|
|
elif call_type == "video":
|
|||
|
|
numbers[phone_number]["video_seconds_used"] += duration_seconds
|
|||
|
|
else:
|
|||
|
|
raise ValueError(f"Invalid call type: {call_type}")
|
|||
|
|
|
|||
|
|
self._save_numbers(numbers)
|
|||
|
|
|
|||
|
|
log.info(
|
|||
|
|
f"Call recorded: {phone_number} {call_type} {duration_seconds}s "
|
|||
|
|
f"({total_cost} Ɉ)"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
"phone_number": phone_number,
|
|||
|
|
"call_type": call_type,
|
|||
|
|
"duration_seconds": duration_seconds,
|
|||
|
|
"price_per_second": price_per_second,
|
|||
|
|
"total_cost": total_cost
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# GLOBAL PHONE SERVICE
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
_phone_service: Optional[PhoneService] = None
|
|||
|
|
|
|||
|
|
def get_phone_service(data_dir: Path = None) -> PhoneService:
|
|||
|
|
"""Получить глобальный экземпляр PhoneService (singleton)"""
|
|||
|
|
global _phone_service
|
|||
|
|
if _phone_service is None:
|
|||
|
|
if data_dir is None:
|
|||
|
|
data_dir = Path(__file__).parent / "data"
|
|||
|
|
_phone_service = PhoneService(data_dir)
|
|||
|
|
return _phone_service
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# CALL PRICING — Audio & Video Second Pricing
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
class CallPricingService:
|
|||
|
|
"""
|
|||
|
|
Аукционная модель цены за секунду аудио/видео звонков.
|
|||
|
|
|
|||
|
|
Принцип: N-я секунда всех звонков стоит N Ɉ
|
|||
|
|
- 1-я секунда аудио в истории Montana = 1 Ɉ
|
|||
|
|
- 2-я секунда аудио = 2 Ɉ
|
|||
|
|
- N-я секунда аудио = N Ɉ
|
|||
|
|
|
|||
|
|
Аналогично для видео (отдельный счетчик).
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def __init__(self, data_dir: Path):
|
|||
|
|
self.data_dir = data_dir
|
|||
|
|
self.auction = get_auction_registry(data_dir)
|
|||
|
|
|
|||
|
|
def get_current_audio_price(self) -> int:
|
|||
|
|
"""Получить текущую цену за 1 секунду аудио"""
|
|||
|
|
return self.auction.get_current_price(ServiceType.AUDIO_SECOND)
|
|||
|
|
|
|||
|
|
def get_current_video_price(self) -> int:
|
|||
|
|
"""Получить текущую цену за 1 секунду видео"""
|
|||
|
|
return self.auction.get_current_price(ServiceType.VIDEO_SECOND)
|
|||
|
|
|
|||
|
|
def calculate_audio_call_cost(self, duration_seconds: int) -> int:
|
|||
|
|
"""
|
|||
|
|
Рассчитать стоимость аудио звонка.
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
duration_seconds: Длительность звонка в секундах
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Общая стоимость в Ɉ
|
|||
|
|
"""
|
|||
|
|
base_price = self.get_current_audio_price()
|
|||
|
|
# Формула: сумма арифметической прогрессии
|
|||
|
|
# cost = base_price + (base_price+1) + ... + (base_price+duration-1)
|
|||
|
|
# = duration * base_price + (0+1+2+...+(duration-1))
|
|||
|
|
# = duration * base_price + duration*(duration-1)/2
|
|||
|
|
cost = duration_seconds * base_price + (duration_seconds * (duration_seconds - 1)) // 2
|
|||
|
|
return cost
|
|||
|
|
|
|||
|
|
def calculate_video_call_cost(self, duration_seconds: int) -> int:
|
|||
|
|
"""
|
|||
|
|
Рассчитать стоимость видео звонка.
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
duration_seconds: Длительность звонка в секундах
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Общая стоимость в Ɉ
|
|||
|
|
"""
|
|||
|
|
base_price = self.get_current_video_price()
|
|||
|
|
cost = duration_seconds * base_price + (duration_seconds * (duration_seconds - 1)) // 2
|
|||
|
|
return cost
|
|||
|
|
|
|||
|
|
def register_audio_seconds(self, seconds: int, caller_address: str) -> Dict:
|
|||
|
|
"""
|
|||
|
|
Зарегистрировать использование N секунд аудио.
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
seconds: Количество секунд
|
|||
|
|
caller_address: Montana адрес звонящего
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Dict с результатом регистрации
|
|||
|
|
"""
|
|||
|
|
total_cost = self.calculate_audio_call_cost(seconds)
|
|||
|
|
|
|||
|
|
# Зарегистрировать каждую секунду в аукционе
|
|||
|
|
for i in range(seconds):
|
|||
|
|
current_price = self.auction.get_current_price(ServiceType.AUDIO_SECOND)
|
|||
|
|
self.auction.purchase(
|
|||
|
|
service_type=ServiceType.AUDIO_SECOND,
|
|||
|
|
buyer_address=caller_address,
|
|||
|
|
service_id=f"audio_{self.auction.get_total_sold(ServiceType.AUDIO_SECOND) + 1}",
|
|||
|
|
amount_paid=current_price
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
"service_type": "audio",
|
|||
|
|
"seconds": seconds,
|
|||
|
|
"total_cost": total_cost,
|
|||
|
|
"caller": caller_address
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def register_video_seconds(self, seconds: int, caller_address: str) -> Dict:
|
|||
|
|
"""
|
|||
|
|
Зарегистрировать использование N секунд видео.
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
seconds: Количество секунд
|
|||
|
|
caller_address: Montana адрес звонящего
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Dict с результатом регистрации
|
|||
|
|
"""
|
|||
|
|
total_cost = self.calculate_video_call_cost(seconds)
|
|||
|
|
|
|||
|
|
# Зарегистрировать каждую секунду в аукционе
|
|||
|
|
for i in range(seconds):
|
|||
|
|
current_price = self.auction.get_current_price(ServiceType.VIDEO_SECOND)
|
|||
|
|
self.auction.purchase(
|
|||
|
|
service_type=ServiceType.VIDEO_SECOND,
|
|||
|
|
buyer_address=caller_address,
|
|||
|
|
service_id=f"video_{self.auction.get_total_sold(ServiceType.VIDEO_SECOND) + 1}",
|
|||
|
|
amount_paid=current_price
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
"service_type": "video",
|
|||
|
|
"seconds": seconds,
|
|||
|
|
"total_cost": total_cost,
|
|||
|
|
"caller": caller_address
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# GLOBAL CALL PRICING SERVICE
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
_call_pricing_service: Optional[CallPricingService] = None
|
|||
|
|
|
|||
|
|
def get_call_pricing_service(data_dir: Path = None) -> CallPricingService:
|
|||
|
|
"""Получить глобальный экземпляр CallPricingService (singleton)"""
|
|||
|
|
global _call_pricing_service
|
|||
|
|
if _call_pricing_service is None:
|
|||
|
|
if data_dir is None:
|
|||
|
|
data_dir = Path(__file__).parent / "data"
|
|||
|
|
_call_pricing_service = CallPricingService(data_dir)
|
|||
|
|
return _call_pricing_service
|