3397 lines
126 KiB
Python
3397 lines
126 KiB
Python
|
|
#!/usr/bin/env python3
|
|||
|
|
"""
|
|||
|
|
Montana Protocol API — Standalone REST API
|
|||
|
|
Fully independent, no Telegram dependencies.
|
|||
|
|
|
|||
|
|
Endpoints:
|
|||
|
|
- /api/health - Node health check
|
|||
|
|
- /api/network - Network status (3 nodes)
|
|||
|
|
- /api/status - Full Montana status
|
|||
|
|
- /api/balance/{addr} - Balance by Montana address (mt...)
|
|||
|
|
- /api/transfer - Transfer Ɉ between addresses
|
|||
|
|
- /api/timechain/* - TimeChain operations
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
from flask import Flask, jsonify, request
|
|||
|
|
from flask_cors import CORS
|
|||
|
|
import json
|
|||
|
|
import os
|
|||
|
|
import threading
|
|||
|
|
import fcntl
|
|||
|
|
import time as time_module
|
|||
|
|
import hashlib
|
|||
|
|
import hmac
|
|||
|
|
import ipaddress
|
|||
|
|
import re
|
|||
|
|
import logging
|
|||
|
|
from pathlib import Path
|
|||
|
|
from datetime import datetime
|
|||
|
|
from collections import defaultdict
|
|||
|
|
from functools import wraps
|
|||
|
|
from decimal import Decimal, ROUND_DOWN, InvalidOperation
|
|||
|
|
|
|||
|
|
# Post-quantum cryptography
|
|||
|
|
from node_crypto import verify_signature, public_key_to_address, validate_address
|
|||
|
|
|
|||
|
|
# Event Sourcing — P2P replication layer
|
|||
|
|
try:
|
|||
|
|
from event_ledger import get_event_ledger, EventLedger, EventType
|
|||
|
|
EVENT_LEDGER_AVAILABLE = True
|
|||
|
|
except ImportError:
|
|||
|
|
EVENT_LEDGER_AVAILABLE = False
|
|||
|
|
|
|||
|
|
# TIME_BANK — T2 finalization engine
|
|||
|
|
try:
|
|||
|
|
from time_bank import get_time_bank
|
|||
|
|
TIME_BANK_AVAILABLE = True
|
|||
|
|
except ImportError:
|
|||
|
|
TIME_BANK_AVAILABLE = False
|
|||
|
|
|
|||
|
|
# AUCTION SYSTEM — Ascending Auction Model
|
|||
|
|
try:
|
|||
|
|
from montana_auction import (
|
|||
|
|
get_auction_registry,
|
|||
|
|
get_domain_service,
|
|||
|
|
get_phone_service,
|
|||
|
|
get_call_pricing_service,
|
|||
|
|
ServiceType
|
|||
|
|
)
|
|||
|
|
AUCTION_AVAILABLE = True
|
|||
|
|
except ImportError:
|
|||
|
|
AUCTION_AVAILABLE = False
|
|||
|
|
|
|||
|
|
# REAL PHONE BINDING — SMS Verification
|
|||
|
|
try:
|
|||
|
|
from montana_real_phone import get_real_phone_service
|
|||
|
|
REAL_PHONE_AVAILABLE = True
|
|||
|
|
except ImportError:
|
|||
|
|
REAL_PHONE_AVAILABLE = False
|
|||
|
|
|
|||
|
|
log = logging.getLogger("montana_api")
|
|||
|
|
|
|||
|
|
app = Flask(__name__)
|
|||
|
|
|
|||
|
|
# [FIX CWE-942] CORS restricted to Montana origins only
|
|||
|
|
ALLOWED_ORIGINS = os.environ.get("CORS_ORIGINS", "").split(",")
|
|||
|
|
if ALLOWED_ORIGINS == [""]:
|
|||
|
|
ALLOWED_ORIGINS = ["https://efir.org"]
|
|||
|
|
|
|||
|
|
CORS(app, resources={
|
|||
|
|
r"/api/*": {
|
|||
|
|
"origins": ALLOWED_ORIGINS,
|
|||
|
|
"methods": ["GET", "POST"],
|
|||
|
|
"allow_headers": ["Content-Type", "Authorization", "X-Montana-Signature",
|
|||
|
|
"X-Address", "X-Timestamp", "X-Signature", "X-Public-Key",
|
|||
|
|
"X-Signature-Algorithm"],
|
|||
|
|
"max_age": 3600
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# CONFIGURATION
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
BOT_DIR = Path(__file__).parent
|
|||
|
|
DATA_DIR = BOT_DIR / "data"
|
|||
|
|
WALLETS_FILE = DATA_DIR / "wallets.json"
|
|||
|
|
REGISTRY_FILE = DATA_DIR / "registry.json" # Реестр адресов с номерами
|
|||
|
|
|
|||
|
|
# [FIX CWE-502] File size limits (Memory DoS protection)
|
|||
|
|
MAX_WALLET_FILE_SIZE = 50 * 1024 * 1024 # 50MB
|
|||
|
|
MAX_WALLETS = 100_000 # [FIX CWE-400] Disk DoS protection
|
|||
|
|
|
|||
|
|
# [FIX CWE-20] Montana address validation
|
|||
|
|
MONTANA_ADDRESS_RE = re.compile(r'^mt[a-f0-9]{40}$')
|
|||
|
|
|
|||
|
|
# Montana Network Nodes
|
|||
|
|
NODES = {
|
|||
|
|
"amsterdam": {"ip": "72.56.102.240", "priority": 1, "location": "🇳🇱 Amsterdam"},
|
|||
|
|
"moscow": {"ip": "176.124.208.93", "priority": 2, "location": "🇷🇺 Moscow"},
|
|||
|
|
"almaty": {"ip": "91.200.148.93", "priority": 3, "location": "🇰🇿 Almaty"}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# P2P Peer nodes for event replication (port 8889 for inter-node sync)
|
|||
|
|
PEER_NODES = [
|
|||
|
|
{"name": "amsterdam", "url": "http://72.56.102.240:8889", "ip": "72.56.102.240"},
|
|||
|
|
{"name": "moscow", "url": "http://176.124.208.93:8889", "ip": "176.124.208.93"},
|
|||
|
|
{"name": "almaty", "url": "http://91.200.148.93:8889", "ip": "91.200.148.93"},
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
# Node identity — determined at startup from local IP
|
|||
|
|
NODE_ID = os.environ.get("MONTANA_NODE_ID", "unknown")
|
|||
|
|
|
|||
|
|
# Connected Mac app clients (dynamic registry)
|
|||
|
|
_mac_peers = {}
|
|||
|
|
_mac_peers_lock = threading.Lock()
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# RATE LIMITING
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
# [FIX R8 CWE-807] Trusted proxy IP resolution
|
|||
|
|
# Only trust X-Real-IP / X-Forwarded-For when direct connection is from localhost (nginx proxy)
|
|||
|
|
_LOCALHOST_IPS = frozenset({'127.0.0.1', '::1'})
|
|||
|
|
|
|||
|
|
def _get_client_ip() -> str:
|
|||
|
|
"""Get real client IP, handling reverse proxy (nginx) on localhost."""
|
|||
|
|
direct_ip = request.remote_addr or 'unknown'
|
|||
|
|
if direct_ip in _LOCALHOST_IPS:
|
|||
|
|
# Connection from local nginx — trust proxy headers
|
|||
|
|
real_ip = request.headers.get('X-Real-IP', '').strip()
|
|||
|
|
if real_ip:
|
|||
|
|
try:
|
|||
|
|
ipaddress.ip_address(real_ip)
|
|||
|
|
return real_ip
|
|||
|
|
except ValueError:
|
|||
|
|
pass
|
|||
|
|
forwarded = request.headers.get('X-Forwarded-For', '').strip()
|
|||
|
|
if forwarded:
|
|||
|
|
first_ip = forwarded.split(',')[0].strip()
|
|||
|
|
try:
|
|||
|
|
ipaddress.ip_address(first_ip)
|
|||
|
|
return first_ip
|
|||
|
|
except ValueError:
|
|||
|
|
pass
|
|||
|
|
return direct_ip
|
|||
|
|
|
|||
|
|
|
|||
|
|
class RateLimiter:
|
|||
|
|
"""Simple in-memory rate limiter with periodic cleanup"""
|
|||
|
|
def __init__(self):
|
|||
|
|
self.requests = defaultdict(list)
|
|||
|
|
self.lock = threading.Lock()
|
|||
|
|
self._last_cleanup = time_module.time()
|
|||
|
|
|
|||
|
|
def is_allowed(self, ip: str, limit: int = 60, window: int = 60) -> bool:
|
|||
|
|
now = time_module.time()
|
|||
|
|
with self.lock:
|
|||
|
|
# [FIX R8 CWE-400] Periodic cleanup of stale entries every 5 minutes
|
|||
|
|
if now - self._last_cleanup > 300:
|
|||
|
|
stale_ips = [k for k, v in self.requests.items()
|
|||
|
|
if not v or now - v[-1] > 300]
|
|||
|
|
for k in stale_ips:
|
|||
|
|
del self.requests[k]
|
|||
|
|
self._last_cleanup = now
|
|||
|
|
|
|||
|
|
self.requests[ip] = [t for t in self.requests[ip] if now - t < window]
|
|||
|
|
if len(self.requests[ip]) >= limit:
|
|||
|
|
return False
|
|||
|
|
self.requests[ip].append(now)
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
_rate_limiter = RateLimiter()
|
|||
|
|
|
|||
|
|
def rate_limit(limit: int = 60, window: int = 60):
|
|||
|
|
"""Decorator for rate limiting endpoints"""
|
|||
|
|
def decorator(f):
|
|||
|
|
@wraps(f)
|
|||
|
|
def decorated_function(*args, **kwargs):
|
|||
|
|
ip = _get_client_ip()
|
|||
|
|
if not _rate_limiter.is_allowed(ip, limit, window):
|
|||
|
|
return jsonify({
|
|||
|
|
"error": "RATE_LIMIT_EXCEEDED",
|
|||
|
|
"message": f"Too many requests. Limit: {limit} per {window}s",
|
|||
|
|
"retry_after": window
|
|||
|
|
}), 429
|
|||
|
|
return f(*args, **kwargs)
|
|||
|
|
return decorated_function
|
|||
|
|
return decorator
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# SIGNATURE VERIFICATION
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
def verify_signature_beta(public_key_hex: str, message: str, signature_hex: str) -> bool:
|
|||
|
|
"""
|
|||
|
|
Verify ML-DSA-65 signature (MAINNET only).
|
|||
|
|
|
|||
|
|
[FIX CWE-320] HMAC-SHA256 "BETA" mode REMOVED — it used public key as HMAC secret,
|
|||
|
|
which means ANYONE with the public key could forge signatures.
|
|||
|
|
Only ML-DSA-65 (3309 bytes / 6618 hex) accepted.
|
|||
|
|
"""
|
|||
|
|
sig_bytes = len(signature_hex) // 2
|
|||
|
|
|
|||
|
|
if sig_bytes == 3309:
|
|||
|
|
# MAINNET: ML-DSA-65 (FIPS 204)
|
|||
|
|
return verify_signature(public_key_hex, message, signature_hex)
|
|||
|
|
|
|||
|
|
else:
|
|||
|
|
log.warning("[Auth] Rejected non-ML-DSA-65 signature: %d bytes (expected 3309)", sig_bytes)
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# WALLET STORAGE
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
_wallet_lock = threading.Lock() # [FIX CWE-362] Process-level lock
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _validate_address(address: str) -> bool:
|
|||
|
|
"""[FIX CWE-20] Validate Montana address format: mt + 40 hex chars"""
|
|||
|
|
return bool(MONTANA_ADDRESS_RE.match(address))
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ── Shared presence queue for TimeChain node ──
|
|||
|
|
_presence_queue_lock = threading.Lock()
|
|||
|
|
PRESENCE_QUEUE_FILE = Path(__file__).parent / "data" / "presence_queue.json"
|
|||
|
|
|
|||
|
|
|
|||
|
|
MAX_PRESENCE_QUEUE_ENTRIES = 10000 # [FIX R7 CWE-400] Prevent disk DoS
|
|||
|
|
|
|||
|
|
def _queue_presence(address: str, seconds: int):
|
|||
|
|
"""Write presence to shared queue file (read by timechain_node at τ₂ boundary)"""
|
|||
|
|
with _presence_queue_lock:
|
|||
|
|
try:
|
|||
|
|
data = {}
|
|||
|
|
if PRESENCE_QUEUE_FILE.exists():
|
|||
|
|
with open(PRESENCE_QUEUE_FILE, 'r') as f:
|
|||
|
|
data = json.load(f)
|
|||
|
|
# [FIX R7 CWE-400] Cap queue size to prevent disk/memory DoS
|
|||
|
|
if address not in data and len(data) >= MAX_PRESENCE_QUEUE_ENTRIES:
|
|||
|
|
log.warning(f"Presence queue full ({MAX_PRESENCE_QUEUE_ENTRIES} entries), rejecting {address[:10]}...")
|
|||
|
|
return
|
|||
|
|
existing = data.get(address, 0)
|
|||
|
|
data[address] = min(existing + seconds, 600) # Max TAU2_SECONDS
|
|||
|
|
with open(PRESENCE_QUEUE_FILE, 'w') as f:
|
|||
|
|
json.dump(data, f)
|
|||
|
|
except Exception as e:
|
|||
|
|
log.warning(f"Presence queue write error: {e}")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def load_wallets() -> dict:
|
|||
|
|
"""Load wallets from JSON file with size validation and file locking"""
|
|||
|
|
if not WALLETS_FILE.exists():
|
|||
|
|
return {}
|
|||
|
|
|
|||
|
|
# [FIX CWE-502] Check file size before loading
|
|||
|
|
try:
|
|||
|
|
file_size = os.path.getsize(WALLETS_FILE)
|
|||
|
|
if file_size > MAX_WALLET_FILE_SIZE:
|
|||
|
|
log.critical("Wallets file too large: %d bytes (max %d)",
|
|||
|
|
file_size, MAX_WALLET_FILE_SIZE)
|
|||
|
|
return {}
|
|||
|
|
except OSError:
|
|||
|
|
return {}
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
with open(WALLETS_FILE, 'r', encoding='utf-8') as f:
|
|||
|
|
# [FIX CWE-362] Shared read lock
|
|||
|
|
fcntl.flock(f.fileno(), fcntl.LOCK_SH)
|
|||
|
|
try:
|
|||
|
|
return json.load(f)
|
|||
|
|
finally:
|
|||
|
|
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
|||
|
|
except json.JSONDecodeError as e:
|
|||
|
|
log.error("Invalid JSON in wallets file: %s", e)
|
|||
|
|
return {}
|
|||
|
|
except Exception as e:
|
|||
|
|
log.error("Failed to load wallets: %s", e)
|
|||
|
|
return {}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def save_wallets(wallets: dict):
|
|||
|
|
"""Save wallets to JSON file with file locking"""
|
|||
|
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|||
|
|
|
|||
|
|
# [FIX CWE-362] Exclusive write lock
|
|||
|
|
with open(WALLETS_FILE, 'w', encoding='utf-8') as f:
|
|||
|
|
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
|||
|
|
try:
|
|||
|
|
json.dump(wallets, f, indent=2, ensure_ascii=False)
|
|||
|
|
finally:
|
|||
|
|
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def get_balance(address: str) -> float:
|
|||
|
|
"""Get balance for Montana address"""
|
|||
|
|
if not _validate_address(address):
|
|||
|
|
return 0.0
|
|||
|
|
wallets = load_wallets()
|
|||
|
|
return wallets.get(address, {}).get('balance', 0.0)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def set_balance(address: str, balance: float):
|
|||
|
|
"""Set balance for Montana address with wallet count limit"""
|
|||
|
|
if not _validate_address(address):
|
|||
|
|
raise ValueError(f"Invalid Montana address: {address}")
|
|||
|
|
|
|||
|
|
with _wallet_lock: # [FIX CWE-362] Thread safety
|
|||
|
|
wallets = load_wallets()
|
|||
|
|
|
|||
|
|
if address not in wallets:
|
|||
|
|
# [FIX CWE-400] Limit total wallet count
|
|||
|
|
if len(wallets) >= MAX_WALLETS:
|
|||
|
|
raise ValueError(f"Maximum wallet limit reached ({MAX_WALLETS})")
|
|||
|
|
wallets[address] = {'created_at': datetime.utcnow().isoformat()}
|
|||
|
|
|
|||
|
|
# [FIX CWE-682] Use string representation for precision
|
|||
|
|
wallets[address]['balance'] = float(Decimal(str(balance)).quantize(
|
|||
|
|
Decimal('0.00000001'), rounding=ROUND_DOWN))
|
|||
|
|
wallets[address]['updated_at'] = datetime.utcnow().isoformat()
|
|||
|
|
save_wallets(wallets)
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# NETWORK STATUS
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
def check_node_status(ip: str) -> dict:
|
|||
|
|
"""Check node status via ping with IP validation"""
|
|||
|
|
import subprocess
|
|||
|
|
|
|||
|
|
# [FIX CWE-78] Validate IP address format before passing to subprocess
|
|||
|
|
try:
|
|||
|
|
ipaddress.ip_address(ip)
|
|||
|
|
except ValueError:
|
|||
|
|
log.warning("Invalid IP in check_node_status: %s", ip)
|
|||
|
|
return {"online": False, "response_time": "invalid_ip"}
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
result = subprocess.run(
|
|||
|
|
['ping', '-c', '1', '-W', '1', ip],
|
|||
|
|
stdout=subprocess.PIPE,
|
|||
|
|
stderr=subprocess.PIPE,
|
|||
|
|
timeout=2,
|
|||
|
|
shell=False # Explicit: prevent shell injection
|
|||
|
|
)
|
|||
|
|
online = result.returncode == 0
|
|||
|
|
return {"online": online, "response_time": "< 100ms" if online else "timeout"}
|
|||
|
|
except subprocess.TimeoutExpired:
|
|||
|
|
return {"online": False, "response_time": "timeout"}
|
|||
|
|
except Exception as e:
|
|||
|
|
log.error("Ping failed for %s: %s", ip, e)
|
|||
|
|
return {"online": False, "response_time": "error"}
|
|||
|
|
|
|||
|
|
def get_network_status() -> dict:
|
|||
|
|
"""Get full Montana network status"""
|
|||
|
|
status = {}
|
|||
|
|
|
|||
|
|
for node_name, node_info in NODES.items():
|
|||
|
|
node_status = check_node_status(node_info["ip"])
|
|||
|
|
status[node_name] = {
|
|||
|
|
**node_info,
|
|||
|
|
**node_status,
|
|||
|
|
"name": node_name.title()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
online_count = sum(1 for n in status.values() if n["online"])
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
"nodes": status,
|
|||
|
|
"summary": {
|
|||
|
|
"total_nodes": len(NODES),
|
|||
|
|
"online_nodes": online_count,
|
|||
|
|
"network_health": f"{(online_count/len(NODES))*100:.0f}%",
|
|||
|
|
"timestamp": datetime.utcnow().isoformat() + "Z"
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# API ENDPOINTS
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
@app.route('/api/health')
|
|||
|
|
def api_health():
|
|||
|
|
"""Quick health check for iOS/clients"""
|
|||
|
|
result = {
|
|||
|
|
"status": "ok",
|
|||
|
|
"node": NODE_ID,
|
|||
|
|
"version": "3.18.0",
|
|||
|
|
"p2p": EVENT_LEDGER_AVAILABLE,
|
|||
|
|
"timestamp": datetime.utcnow().isoformat() + "Z"
|
|||
|
|
}
|
|||
|
|
if EVENT_LEDGER_AVAILABLE:
|
|||
|
|
try:
|
|||
|
|
ledger = get_event_ledger()
|
|||
|
|
result["ledger_events"] = ledger._event_counter
|
|||
|
|
result["ledger_node_id"] = ledger.node_id
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
return jsonify(result)
|
|||
|
|
|
|||
|
|
@app.route('/api/network')
|
|||
|
|
def api_network():
|
|||
|
|
"""Network status only"""
|
|||
|
|
return jsonify(get_network_status())
|
|||
|
|
|
|||
|
|
@app.route('/api/status')
|
|||
|
|
def api_status():
|
|||
|
|
"""Full Montana status"""
|
|||
|
|
network = get_network_status()
|
|||
|
|
|
|||
|
|
result = {
|
|||
|
|
"network": network,
|
|||
|
|
"montana": {
|
|||
|
|
"version": "3.18.0",
|
|||
|
|
"mode": "MAINNET",
|
|||
|
|
"crypto": "ML-DSA-65 (FIPS 204)",
|
|||
|
|
"node_id": NODE_ID,
|
|||
|
|
"p2p_enabled": EVENT_LEDGER_AVAILABLE
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if EVENT_LEDGER_AVAILABLE:
|
|||
|
|
try:
|
|||
|
|
ledger = get_event_ledger()
|
|||
|
|
result["montana"]["ledger"] = ledger.stats()
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
return jsonify(result)
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# WALLET ENDPOINTS
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
@app.route('/api/balance/<address>')
|
|||
|
|
@rate_limit(limit=100, window=60)
|
|||
|
|
def api_balance(address: str):
|
|||
|
|
"""
|
|||
|
|
Get balance for Montana address.
|
|||
|
|
Post-quantum: ML-DSA-65 signed (FIPS 204).
|
|||
|
|
|
|||
|
|
Address format: mt + 40 hex chars (e.g., mta46b633d258059b90db46adffc6c5ca08f0e8d6c)
|
|||
|
|
Client signs: "BALANCE:{address}:{timestamp}"
|
|||
|
|
"""
|
|||
|
|
# [FIX CWE-20] Validate address format with regex
|
|||
|
|
if not _validate_address(address):
|
|||
|
|
return jsonify({
|
|||
|
|
"error": "INVALID_ADDRESS",
|
|||
|
|
"message": "Address must match format: mt + 40 hex chars"
|
|||
|
|
}), 400
|
|||
|
|
|
|||
|
|
# PQ verification for signed balance requests
|
|||
|
|
address_hdr = request.headers.get('X-Address', '')
|
|||
|
|
timestamp_hdr = request.headers.get('X-Timestamp', '')
|
|||
|
|
verified = False
|
|||
|
|
if address_hdr and timestamp_hdr:
|
|||
|
|
pq_message = f"BALANCE:{address_hdr}:{timestamp_hdr}"
|
|||
|
|
_, verified = _verify_pq_signature(request, message=pq_message)
|
|||
|
|
|
|||
|
|
# Use EventLedger as single source of truth (not wallets.json cache)
|
|||
|
|
if EVENT_LEDGER_AVAILABLE:
|
|||
|
|
try:
|
|||
|
|
ledger = get_event_ledger()
|
|||
|
|
balance = ledger.balance(address)
|
|||
|
|
except Exception:
|
|||
|
|
balance = get_balance(address)
|
|||
|
|
else:
|
|||
|
|
balance = get_balance(address)
|
|||
|
|
|
|||
|
|
return jsonify({
|
|||
|
|
"address": address,
|
|||
|
|
"balance": balance,
|
|||
|
|
"symbol": "Ɉ",
|
|||
|
|
"pq_verified": verified,
|
|||
|
|
"timestamp": datetime.utcnow().isoformat() + "Z"
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
@app.route('/api/transfer', methods=['POST'])
|
|||
|
|
@rate_limit(limit=10, window=60)
|
|||
|
|
def api_transfer():
|
|||
|
|
"""
|
|||
|
|
Transfer Ɉ between Montana addresses.
|
|||
|
|
|
|||
|
|
Required fields:
|
|||
|
|
- from_address: sender's Montana address (mt...)
|
|||
|
|
- to_address: recipient's Montana address (mt...)
|
|||
|
|
- amount: amount to transfer (float)
|
|||
|
|
- signature: ML-DSA-65 signature of message
|
|||
|
|
- public_key: sender's public key (hex)
|
|||
|
|
|
|||
|
|
Message to sign: "TRANSFER:{from}:{to}:{amount}:{timestamp}"
|
|||
|
|
"""
|
|||
|
|
data = request.get_json()
|
|||
|
|
|
|||
|
|
if not data:
|
|||
|
|
return jsonify({"error": "NO_DATA", "message": "Request body required"}), 400
|
|||
|
|
|
|||
|
|
# Required fields
|
|||
|
|
from_addr = data.get('from_address')
|
|||
|
|
to_addr = data.get('to_address')
|
|||
|
|
amount = data.get('amount')
|
|||
|
|
signature = data.get('signature')
|
|||
|
|
public_key = data.get('public_key')
|
|||
|
|
timestamp = data.get('timestamp')
|
|||
|
|
|
|||
|
|
# Validate required fields (signature/public_key optional for registered wallets)
|
|||
|
|
if not all([from_addr, to_addr, amount, timestamp]):
|
|||
|
|
return jsonify({
|
|||
|
|
"error": "MISSING_FIELDS",
|
|||
|
|
"message": "Required: from_address, to_address, amount, timestamp"
|
|||
|
|
}), 400
|
|||
|
|
|
|||
|
|
# [FIX CWE-20] Validate addresses with regex
|
|||
|
|
for addr, name in [(from_addr, 'from_address'), (to_addr, 'to_address')]:
|
|||
|
|
if not _validate_address(addr):
|
|||
|
|
return jsonify({
|
|||
|
|
"error": "INVALID_ADDRESS",
|
|||
|
|
"message": f"{name} must match format: mt + 40 hex chars"
|
|||
|
|
}), 400
|
|||
|
|
|
|||
|
|
# [FIX CWE-682] Validate amount with Decimal precision
|
|||
|
|
try:
|
|||
|
|
amount_dec = Decimal(str(amount))
|
|||
|
|
if amount_dec <= 0:
|
|||
|
|
raise ValueError("Amount must be positive")
|
|||
|
|
amount_dec = amount_dec.quantize(Decimal('0.00000001'), rounding=ROUND_DOWN)
|
|||
|
|
# [FIX R10 CWE-697] Reject amounts that round to zero after quantization
|
|||
|
|
if amount_dec <= 0:
|
|||
|
|
raise ValueError("Amount rounds to zero (minimum: 0.00000001)")
|
|||
|
|
# [FIX CWE-682 R5] Keep Decimal for balance arithmetic — no float() conversion
|
|||
|
|
# float64 loses precision beyond ~15 significant digits; Decimal is exact
|
|||
|
|
amount = amount_dec
|
|||
|
|
except (ValueError, InvalidOperation) as e:
|
|||
|
|
return jsonify({"error": "INVALID_AMOUNT", "message": str(e)}), 400
|
|||
|
|
|
|||
|
|
# Verify sender identity
|
|||
|
|
if signature and public_key:
|
|||
|
|
# ML-DSA-65 signed transfer (full verification)
|
|||
|
|
derived_address = public_key_to_address(public_key)
|
|||
|
|
if derived_address != from_addr:
|
|||
|
|
return jsonify({
|
|||
|
|
"error": "ADDRESS_MISMATCH",
|
|||
|
|
"message": "Public key does not match from_address"
|
|||
|
|
}), 403
|
|||
|
|
|
|||
|
|
message = f"TRANSFER:{from_addr}:{to_addr}:{amount}:{timestamp}"
|
|||
|
|
if not verify_signature_beta(public_key, message, signature):
|
|||
|
|
return jsonify({
|
|||
|
|
"error": "INVALID_SIGNATURE",
|
|||
|
|
"message": "Signature verification failed"
|
|||
|
|
}), 403
|
|||
|
|
else:
|
|||
|
|
# [FIX R8 CWE-306] Unsigned transfers REJECTED — ML-DSA-65 signature required
|
|||
|
|
# Previously allowed for "registered wallets" but provides zero auth
|
|||
|
|
# (anyone knowing the address could drain funds)
|
|||
|
|
return jsonify({
|
|||
|
|
"error": "SIGNATURE_REQUIRED",
|
|||
|
|
"message": "All transfers require ML-DSA-65 signature (FIPS 204)"
|
|||
|
|
}), 403
|
|||
|
|
|
|||
|
|
# [FIX CWE-367] Timestamp freshness — reject stale/future requests
|
|||
|
|
try:
|
|||
|
|
ts_val = int(timestamp) if str(timestamp).isdigit() else int(float(timestamp))
|
|||
|
|
now_ms = int(time_module.time() * 1000)
|
|||
|
|
# Allow 5 min drift (300_000 ms) for clock skew
|
|||
|
|
if abs(now_ms - ts_val) > 300_000:
|
|||
|
|
return jsonify({
|
|||
|
|
"error": "STALE_TIMESTAMP",
|
|||
|
|
"message": "Timestamp too old or too far in future (max 5 min drift)"
|
|||
|
|
}), 400
|
|||
|
|
except (ValueError, TypeError):
|
|||
|
|
# [FIX CWE-294] Reject non-numeric timestamps (replay attack vector)
|
|||
|
|
return jsonify({
|
|||
|
|
"error": "INVALID_TIMESTAMP",
|
|||
|
|
"message": "Timestamp must be numeric (milliseconds since epoch)"
|
|||
|
|
}), 400
|
|||
|
|
|
|||
|
|
# [FIX] Self-transfer check
|
|||
|
|
if from_addr == to_addr:
|
|||
|
|
return jsonify({
|
|||
|
|
"error": "SELF_TRANSFER",
|
|||
|
|
"message": "Cannot transfer to self"
|
|||
|
|
}), 400
|
|||
|
|
|
|||
|
|
# [FIX CWE-362] Generate unique tx_id with full SHA256 (not truncated)
|
|||
|
|
tx_nonce = os.urandom(16).hex()
|
|||
|
|
tx_id = hashlib.sha256(
|
|||
|
|
f"{from_addr}{to_addr}{amount}{timestamp}{tx_nonce}".encode()
|
|||
|
|
).hexdigest()
|
|||
|
|
|
|||
|
|
# Determine if this was a PQ-signed transfer
|
|||
|
|
is_pq_signed = bool(signature and public_key)
|
|||
|
|
|
|||
|
|
# [FIX CWE-362] Atomic balance check + transfer via EventLedger
|
|||
|
|
# EventLedger.transfer() does balance check internally (skip_balance_check=False)
|
|||
|
|
if EVENT_LEDGER_AVAILABLE:
|
|||
|
|
try:
|
|||
|
|
ledger = get_event_ledger()
|
|||
|
|
ok, msg, event = ledger.transfer(from_addr, to_addr, float(amount), metadata={
|
|||
|
|
"tx_id": tx_id,
|
|||
|
|
"timestamp": timestamp,
|
|||
|
|
"pq_signed": is_pq_signed
|
|||
|
|
}, skip_balance_check=False)
|
|||
|
|
if not ok:
|
|||
|
|
return jsonify({
|
|||
|
|
"error": "TRANSFER_REJECTED",
|
|||
|
|
"message": msg
|
|||
|
|
}), 400
|
|||
|
|
if event:
|
|||
|
|
_push_event_to_peers(event.to_dict())
|
|||
|
|
except Exception as e:
|
|||
|
|
log.error(f"EventLedger transfer error: {e}")
|
|||
|
|
return jsonify({
|
|||
|
|
"error": "LEDGER_ERROR",
|
|||
|
|
"message": str(e)
|
|||
|
|
}), 500
|
|||
|
|
else:
|
|||
|
|
# Fallback: wallets.json atomic transfer
|
|||
|
|
with _wallet_lock:
|
|||
|
|
wallets = load_wallets()
|
|||
|
|
# [FIX CWE-682 R5] Use Decimal for balance comparison to prevent precision loss
|
|||
|
|
sender_balance = Decimal(str(wallets.get(from_addr, {}).get('balance', 0)))
|
|||
|
|
if sender_balance < amount:
|
|||
|
|
return jsonify({
|
|||
|
|
"error": "INSUFFICIENT_FUNDS",
|
|||
|
|
"message": f"Balance: {sender_balance} Ɉ, required: {amount} Ɉ"
|
|||
|
|
}), 400
|
|||
|
|
wallets[from_addr]['balance'] = float(sender_balance - amount)
|
|||
|
|
if to_addr not in wallets:
|
|||
|
|
wallets[to_addr] = {'balance': 0.0, 'created_at': datetime.utcnow().isoformat()}
|
|||
|
|
recv_bal = Decimal(str(wallets[to_addr].get('balance', 0)))
|
|||
|
|
wallets[to_addr]['balance'] = float(recv_bal + amount)
|
|||
|
|
save_wallets(wallets)
|
|||
|
|
|
|||
|
|
return jsonify({
|
|||
|
|
"status": "success",
|
|||
|
|
"tx_id": tx_id,
|
|||
|
|
"from": from_addr,
|
|||
|
|
"to": to_addr,
|
|||
|
|
"amount": float(amount),
|
|||
|
|
"symbol": "Ɉ",
|
|||
|
|
"pq_signed": is_pq_signed,
|
|||
|
|
"timestamp": datetime.utcnow().isoformat() + "Z"
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
@app.route('/api/register', methods=['POST'])
|
|||
|
|
@rate_limit(limit=5, window=60)
|
|||
|
|
def api_register():
|
|||
|
|
"""
|
|||
|
|
Register new wallet with public key.
|
|||
|
|
|
|||
|
|
Required fields:
|
|||
|
|
- public_key: ML-DSA-65 public key (hex, 3904 chars = 1952 bytes)
|
|||
|
|
|
|||
|
|
Returns Montana address derived from public key.
|
|||
|
|
"""
|
|||
|
|
data = request.get_json()
|
|||
|
|
|
|||
|
|
if not data:
|
|||
|
|
return jsonify({"error": "NO_DATA", "message": "Request body required"}), 400
|
|||
|
|
|
|||
|
|
public_key = data.get('public_key')
|
|||
|
|
|
|||
|
|
if not public_key:
|
|||
|
|
return jsonify({"error": "MISSING_PUBLIC_KEY", "message": "public_key required"}), 400
|
|||
|
|
|
|||
|
|
# Validate public key length (1952 bytes = 3904 hex chars)
|
|||
|
|
if len(public_key) != 3904:
|
|||
|
|
return jsonify({
|
|||
|
|
"error": "INVALID_PUBLIC_KEY",
|
|||
|
|
"message": f"Public key must be 3904 hex chars (1952 bytes), got {len(public_key)}"
|
|||
|
|
}), 400
|
|||
|
|
|
|||
|
|
# Derive address
|
|||
|
|
try:
|
|||
|
|
address = public_key_to_address(public_key)
|
|||
|
|
except Exception as e:
|
|||
|
|
return jsonify({"error": "KEY_DERIVATION_ERROR", "message": str(e)}), 400
|
|||
|
|
|
|||
|
|
# [FIX R9 CWE-362] Atomic wallet mutation under lock
|
|||
|
|
with _wallet_lock:
|
|||
|
|
wallets = load_wallets()
|
|||
|
|
if address not in wallets:
|
|||
|
|
wallets[address] = {
|
|||
|
|
'balance': 0.0,
|
|||
|
|
'public_key': public_key,
|
|||
|
|
'created_at': datetime.utcnow().isoformat()
|
|||
|
|
}
|
|||
|
|
save_wallets(wallets)
|
|||
|
|
balance = wallets[address].get('balance', 0.0)
|
|||
|
|
|
|||
|
|
return jsonify({
|
|||
|
|
"status": "success",
|
|||
|
|
"address": address,
|
|||
|
|
"balance": balance,
|
|||
|
|
"symbol": "Ɉ"
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# WALLET REGISTRY (Sequential Numbers)
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
def load_registry():
|
|||
|
|
"""Load wallet registry with sequential numbers"""
|
|||
|
|
if REGISTRY_FILE.exists():
|
|||
|
|
try:
|
|||
|
|
with open(REGISTRY_FILE, 'r') as f:
|
|||
|
|
return json.load(f)
|
|||
|
|
except (json.JSONDecodeError, OSError) as e:
|
|||
|
|
log.error("Failed to load registry: %s", e)
|
|||
|
|
return {"next_number": 1, "wallets": {}}
|
|||
|
|
|
|||
|
|
def save_registry(registry):
|
|||
|
|
"""Save wallet registry"""
|
|||
|
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|||
|
|
with open(REGISTRY_FILE, 'w') as f:
|
|||
|
|
json.dump(registry, f, indent=2)
|
|||
|
|
|
|||
|
|
_registry_lock = threading.Lock()
|
|||
|
|
|
|||
|
|
@app.route('/api/wallet/register', methods=['POST'])
|
|||
|
|
@rate_limit(limit=10, window=60)
|
|||
|
|
def api_wallet_register():
|
|||
|
|
"""
|
|||
|
|
Register wallet address and get sequential number.
|
|||
|
|
Optionally set @alias (custom nickname).
|
|||
|
|
|
|||
|
|
POST /api/wallet/register
|
|||
|
|
Body: { "address": "mt...", "alias": "@montana" } // alias optional
|
|||
|
|
|
|||
|
|
Response: { "number": 1, "address": "Ɉ-1-hash", "alias": "Ɉ-1", "custom_alias": "@montana" }
|
|||
|
|
|
|||
|
|
Unified address formats:
|
|||
|
|
- Ɉ-{N} — short (enough for transfers)
|
|||
|
|
- Ɉ-{N}-{hash} — full (verifiable)
|
|||
|
|
- @nickname — custom alias
|
|||
|
|
- mt{hash} — crypto (internal)
|
|||
|
|
"""
|
|||
|
|
data = request.get_json()
|
|||
|
|
|
|||
|
|
if not data:
|
|||
|
|
return jsonify({"error": "NO_DATA"}), 400
|
|||
|
|
|
|||
|
|
address = data.get('address', '')
|
|||
|
|
custom_alias = data.get('alias', '').strip()
|
|||
|
|
|
|||
|
|
# [FIX R7 CWE-862] Require proof of ownership for registration
|
|||
|
|
# [FIX R8 CWE-294] Include timestamp to prevent replay attacks
|
|||
|
|
mt_addr = address if address.startswith('mt') else None
|
|||
|
|
if mt_addr:
|
|||
|
|
timestamp_hdr = request.headers.get('X-Timestamp', '')
|
|||
|
|
pq_message = f"REGISTER:{mt_addr}:{custom_alias or ''}:{timestamp_hdr}"
|
|||
|
|
_, verified = _verify_pq_signature(request, message=pq_message)
|
|||
|
|
if not verified:
|
|||
|
|
client_ip = _get_client_ip()
|
|||
|
|
if client_ip not in ('127.0.0.1', '::1'):
|
|||
|
|
return jsonify({"error": "SIGNATURE_REQUIRED",
|
|||
|
|
"message": "Wallet registration requires ML-DSA-65 signature"}), 403
|
|||
|
|
|
|||
|
|
# Validate @alias format: @[a-zA-Z0-9_]{1,20}
|
|||
|
|
if custom_alias and not re.match(r'^@[a-zA-Z0-9_]{1,20}$', custom_alias):
|
|||
|
|
return jsonify({"error": "INVALID_ALIAS",
|
|||
|
|
"message": "Alias must be @name (1-20 alphanumeric/underscore)"}), 400
|
|||
|
|
|
|||
|
|
# Parse address
|
|||
|
|
if address.startswith('mt'):
|
|||
|
|
crypto_hash = address[2:]
|
|||
|
|
elif address.startswith('Ɉ-'):
|
|||
|
|
parts = address.split('-')
|
|||
|
|
if len(parts) >= 3:
|
|||
|
|
crypto_hash = parts[-1]
|
|||
|
|
else:
|
|||
|
|
return jsonify({"error": "INVALID_ADDRESS"}), 400
|
|||
|
|
else:
|
|||
|
|
return jsonify({"error": "INVALID_ADDRESS", "message": "Address must start with 'mt' or 'Ɉ-'"}), 400
|
|||
|
|
|
|||
|
|
if len(crypto_hash) < 10:
|
|||
|
|
return jsonify({"error": "INVALID_ADDRESS", "message": "Hash too short"}), 400
|
|||
|
|
|
|||
|
|
with _registry_lock:
|
|||
|
|
registry = load_registry()
|
|||
|
|
|
|||
|
|
# Ensure aliases dict exists
|
|||
|
|
if "aliases" not in registry:
|
|||
|
|
registry["aliases"] = {}
|
|||
|
|
|
|||
|
|
# Check @alias uniqueness
|
|||
|
|
if custom_alias:
|
|||
|
|
alias_lower = custom_alias.lower()
|
|||
|
|
existing_owner = registry["aliases"].get(alias_lower)
|
|||
|
|
if existing_owner and existing_owner != crypto_hash:
|
|||
|
|
return jsonify({"error": "ALIAS_TAKEN",
|
|||
|
|
"message": f"{custom_alias} is already taken"}), 409
|
|||
|
|
|
|||
|
|
# Register or get existing
|
|||
|
|
if crypto_hash in registry["wallets"]:
|
|||
|
|
wallet_data = registry["wallets"][crypto_hash]
|
|||
|
|
number = wallet_data["number"]
|
|||
|
|
else:
|
|||
|
|
number = registry["next_number"]
|
|||
|
|
registry["wallets"][crypto_hash] = {
|
|||
|
|
"number": number,
|
|||
|
|
"registered_at": datetime.utcnow().isoformat() + "Z"
|
|||
|
|
}
|
|||
|
|
registry["next_number"] = number + 1
|
|||
|
|
|
|||
|
|
# Set @alias
|
|||
|
|
if custom_alias:
|
|||
|
|
# Remove old alias for this hash if any
|
|||
|
|
old_aliases = [k for k, v in registry["aliases"].items() if v == crypto_hash]
|
|||
|
|
for old in old_aliases:
|
|||
|
|
del registry["aliases"][old]
|
|||
|
|
registry["aliases"][custom_alias.lower()] = crypto_hash
|
|||
|
|
registry["wallets"][crypto_hash]["custom_alias"] = custom_alias
|
|||
|
|
|
|||
|
|
save_registry(registry)
|
|||
|
|
|
|||
|
|
stored_alias = registry["wallets"][crypto_hash].get("custom_alias", "")
|
|||
|
|
|
|||
|
|
return jsonify({
|
|||
|
|
"status": "success",
|
|||
|
|
"number": number,
|
|||
|
|
"alias": f"Ɉ-{number}",
|
|||
|
|
"custom_alias": stored_alias,
|
|||
|
|
"address": f"Ɉ-{number}-{crypto_hash}",
|
|||
|
|
"crypto_hash": crypto_hash
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
@app.route('/api/agent/register', methods=['POST'])
|
|||
|
|
@rate_limit(limit=10, window=60)
|
|||
|
|
def api_agent_register():
|
|||
|
|
"""
|
|||
|
|
AI Agent wallet registration.
|
|||
|
|
Simplified flow for AI agents to create and manage wallets.
|
|||
|
|
|
|||
|
|
POST /api/agent/register
|
|||
|
|
Body: {
|
|||
|
|
"agent_name": "MyAIAgent", # required — agent identifier
|
|||
|
|
"public_key": "hex...", # optional — ML-DSA-65 key
|
|||
|
|
"alias": "@myagent" # optional — custom alias
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Returns: { "address": "mt...", "number": N, "alias": "Ɉ-N", "balance": 0 }
|
|||
|
|
"""
|
|||
|
|
data = request.get_json()
|
|||
|
|
if not data:
|
|||
|
|
return jsonify({"error": "NO_DATA"}), 400
|
|||
|
|
|
|||
|
|
agent_name = data.get('agent_name', '').strip()
|
|||
|
|
if not agent_name or len(agent_name) > 64:
|
|||
|
|
return jsonify({
|
|||
|
|
"error": "INVALID_AGENT_NAME",
|
|||
|
|
"message": "agent_name required (1-64 chars)"
|
|||
|
|
}), 400
|
|||
|
|
|
|||
|
|
# [FIX CWE-20] Strict agent name validation — alphanumeric, hyphen, underscore only
|
|||
|
|
if not re.match(r'^[a-zA-Z0-9_-]{1,64}$', agent_name):
|
|||
|
|
return jsonify({
|
|||
|
|
"error": "INVALID_AGENT_NAME",
|
|||
|
|
"message": "agent_name: alphanumeric, hyphen, underscore only (1-64 chars)"
|
|||
|
|
}), 400
|
|||
|
|
|
|||
|
|
# Reserved names cannot be registered
|
|||
|
|
RESERVED_AGENT_NAMES = {'admin', 'root', 'system', 'montana', 'api', 'www', 'server', 'node'}
|
|||
|
|
if agent_name.lower() in RESERVED_AGENT_NAMES:
|
|||
|
|
return jsonify({"error": "RESERVED_NAME", "message": "This agent name is reserved"}), 400
|
|||
|
|
|
|||
|
|
public_key = data.get('public_key', '')
|
|||
|
|
custom_alias = data.get('alias', '').strip()
|
|||
|
|
|
|||
|
|
# Generate address from public key or agent name + server entropy
|
|||
|
|
if public_key and len(public_key) == 3904:
|
|||
|
|
address = public_key_to_address(public_key)
|
|||
|
|
else:
|
|||
|
|
# [FIX CWE-330] Check if agent already registered (idempotent)
|
|||
|
|
# Use deterministic hash for same agent_name to ensure same address
|
|||
|
|
agent_hash = hashlib.sha256(f"agent:montana:{agent_name}".encode()).hexdigest()[:40]
|
|||
|
|
address = f"mt{agent_hash}"
|
|||
|
|
|
|||
|
|
# Initialize wallet if new
|
|||
|
|
with _wallet_lock:
|
|||
|
|
wallets = load_wallets()
|
|||
|
|
if address not in wallets:
|
|||
|
|
if len(wallets) >= MAX_WALLETS:
|
|||
|
|
return jsonify({"error": "MAX_WALLETS_REACHED"}), 429
|
|||
|
|
wallets[address] = {
|
|||
|
|
'balance': 0.0,
|
|||
|
|
'created_at': datetime.utcnow().isoformat(),
|
|||
|
|
'type': 'ai_agent',
|
|||
|
|
'agent_name': agent_name
|
|||
|
|
}
|
|||
|
|
if public_key:
|
|||
|
|
wallets[address]['public_key'] = public_key
|
|||
|
|
save_wallets(wallets)
|
|||
|
|
|
|||
|
|
# Register in wallet registry for sequential number
|
|||
|
|
with _registry_lock:
|
|||
|
|
registry = load_registry()
|
|||
|
|
if "aliases" not in registry:
|
|||
|
|
registry["aliases"] = {}
|
|||
|
|
|
|||
|
|
crypto_hash = address[2:] # remove 'mt' prefix
|
|||
|
|
|
|||
|
|
if crypto_hash not in registry["wallets"]:
|
|||
|
|
number = registry["next_number"]
|
|||
|
|
registry["wallets"][crypto_hash] = {
|
|||
|
|
"number": number,
|
|||
|
|
"registered_at": datetime.utcnow().isoformat() + "Z",
|
|||
|
|
"type": "ai_agent",
|
|||
|
|
"agent_name": agent_name
|
|||
|
|
}
|
|||
|
|
registry["next_number"] = number + 1
|
|||
|
|
else:
|
|||
|
|
number = registry["wallets"][crypto_hash]["number"]
|
|||
|
|
|
|||
|
|
# Set alias if provided
|
|||
|
|
if custom_alias and re.match(r'^@[a-zA-Z0-9_]{1,20}$', custom_alias):
|
|||
|
|
alias_lower = custom_alias.lower()
|
|||
|
|
existing_owner = registry["aliases"].get(alias_lower)
|
|||
|
|
if existing_owner and existing_owner != crypto_hash:
|
|||
|
|
save_registry(registry)
|
|||
|
|
return jsonify({"error": "ALIAS_TAKEN",
|
|||
|
|
"message": f"{custom_alias} is already taken"}), 409
|
|||
|
|
old_aliases = [k for k, v in registry["aliases"].items() if v == crypto_hash]
|
|||
|
|
for old in old_aliases:
|
|||
|
|
del registry["aliases"][old]
|
|||
|
|
registry["aliases"][alias_lower] = crypto_hash
|
|||
|
|
registry["wallets"][crypto_hash]["custom_alias"] = custom_alias
|
|||
|
|
|
|||
|
|
save_registry(registry)
|
|||
|
|
|
|||
|
|
# Use EventLedger as single source of truth (not wallets.json cache)
|
|||
|
|
if EVENT_LEDGER_AVAILABLE:
|
|||
|
|
try:
|
|||
|
|
ledger = get_event_ledger()
|
|||
|
|
balance = ledger.balance(address)
|
|||
|
|
except Exception:
|
|||
|
|
balance = get_balance(address)
|
|||
|
|
else:
|
|||
|
|
balance = get_balance(address)
|
|||
|
|
stored_alias = registry["wallets"][crypto_hash].get("custom_alias", "")
|
|||
|
|
|
|||
|
|
return jsonify({
|
|||
|
|
"status": "success",
|
|||
|
|
"address": address,
|
|||
|
|
"number": number,
|
|||
|
|
"alias": f"\u0248-{number}",
|
|||
|
|
"custom_alias": stored_alias,
|
|||
|
|
"balance": int(balance),
|
|||
|
|
"symbol": "\u0248",
|
|||
|
|
"agent_name": agent_name
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.route('/api/agent/transfer', methods=['POST'])
|
|||
|
|
@rate_limit(limit=10, window=60)
|
|||
|
|
def api_agent_transfer():
|
|||
|
|
"""
|
|||
|
|
AI Agent transfer — simplified transfer for registered AI agents.
|
|||
|
|
No ML-DSA-65 signature required (agents use deterministic addresses).
|
|||
|
|
|
|||
|
|
POST /api/agent/transfer
|
|||
|
|
Body: {
|
|||
|
|
"from_agent": "Amsterdam-Montana", # agent_name (must match registration)
|
|||
|
|
"to_address": "mt..." or number, # recipient (address, number, or @alias)
|
|||
|
|
"amount": 100 # amount in Ɉ (integer)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Security: agent_name → deterministic address derivation (same as registration).
|
|||
|
|
Only wallets with type=ai_agent can use this endpoint.
|
|||
|
|
"""
|
|||
|
|
# [FIX R6 CWE-284] Agent transfers only from trusted sources (localhost or peer nodes)
|
|||
|
|
client_ip = _get_client_ip()
|
|||
|
|
trusted_ips = {'127.0.0.1', '::1'} | {p['ip'] for p in PEER_NODES}
|
|||
|
|
if client_ip not in trusted_ips:
|
|||
|
|
log.warning(f"Agent transfer rejected from untrusted IP: {client_ip}")
|
|||
|
|
return jsonify({"error": "UNAUTHORIZED",
|
|||
|
|
"message": "Agent transfers only from trusted nodes"}), 403
|
|||
|
|
|
|||
|
|
data = request.get_json()
|
|||
|
|
if not data:
|
|||
|
|
return jsonify({"error": "NO_DATA"}), 400
|
|||
|
|
|
|||
|
|
agent_name = data.get('from_agent', '').strip()
|
|||
|
|
to_input = str(data.get('to_address', '')).strip()
|
|||
|
|
amount = data.get('amount')
|
|||
|
|
|
|||
|
|
if not agent_name or not to_input or not amount:
|
|||
|
|
return jsonify({"error": "MISSING_FIELDS",
|
|||
|
|
"message": "Required: from_agent, to_address, amount"}), 400
|
|||
|
|
|
|||
|
|
# Validate agent name format
|
|||
|
|
if not re.match(r'^[a-zA-Z0-9_-]{1,64}$', agent_name):
|
|||
|
|
return jsonify({"error": "INVALID_AGENT_NAME"}), 400
|
|||
|
|
|
|||
|
|
# Derive sender address deterministically (must match registration)
|
|||
|
|
agent_hash = hashlib.sha256(f"agent:montana:{agent_name}".encode()).hexdigest()[:40]
|
|||
|
|
from_addr = f"mt{agent_hash}"
|
|||
|
|
|
|||
|
|
# Verify sender is a registered AI agent
|
|||
|
|
wallets = load_wallets()
|
|||
|
|
if from_addr not in wallets or wallets[from_addr].get('type') != 'ai_agent':
|
|||
|
|
return jsonify({"error": "NOT_AN_AGENT",
|
|||
|
|
"message": "from_agent must be a registered AI agent"}), 403
|
|||
|
|
|
|||
|
|
# Resolve recipient
|
|||
|
|
to_addr = to_input
|
|||
|
|
if not to_input.startswith('mt') or len(to_input) != 42:
|
|||
|
|
# Try resolve by number or alias
|
|||
|
|
registry = load_registry()
|
|||
|
|
aliases = registry.get("aliases", {})
|
|||
|
|
crypto_hash = None
|
|||
|
|
|
|||
|
|
if to_input.startswith('@'):
|
|||
|
|
crypto_hash = aliases.get(to_input.lower())
|
|||
|
|
elif to_input.startswith('\u0248-'):
|
|||
|
|
try:
|
|||
|
|
num = int(to_input[2:])
|
|||
|
|
for h, w in registry["wallets"].items():
|
|||
|
|
if w.get("number") == num:
|
|||
|
|
crypto_hash = h
|
|||
|
|
break
|
|||
|
|
except ValueError:
|
|||
|
|
pass
|
|||
|
|
elif to_input.isdigit():
|
|||
|
|
num = int(to_input)
|
|||
|
|
for h, w in registry["wallets"].items():
|
|||
|
|
if w.get("number") == num:
|
|||
|
|
crypto_hash = h
|
|||
|
|
break
|
|||
|
|
|
|||
|
|
if crypto_hash:
|
|||
|
|
to_addr = f"mt{crypto_hash}"
|
|||
|
|
else:
|
|||
|
|
return jsonify({"error": "RECIPIENT_NOT_FOUND"}), 404
|
|||
|
|
|
|||
|
|
# [FIX CWE-20] Validate resolved to_addr format
|
|||
|
|
if not _validate_address(to_addr):
|
|||
|
|
return jsonify({"error": "INVALID_ADDRESS",
|
|||
|
|
"message": "Resolved recipient address is invalid"}), 400
|
|||
|
|
|
|||
|
|
# Validate amount
|
|||
|
|
try:
|
|||
|
|
amount = int(amount)
|
|||
|
|
if amount <= 0:
|
|||
|
|
raise ValueError("Amount must be positive")
|
|||
|
|
except (ValueError, TypeError) as e:
|
|||
|
|
return jsonify({"error": "INVALID_AMOUNT", "message": str(e)}), 400
|
|||
|
|
|
|||
|
|
# Self-send check
|
|||
|
|
if from_addr == to_addr:
|
|||
|
|
return jsonify({"error": "SELF_TRANSFER", "message": "Cannot transfer to self"}), 400
|
|||
|
|
|
|||
|
|
# [FIX CWE-362 R5] Atomic balance check + transfer — single source of truth
|
|||
|
|
timestamp = datetime.utcnow().isoformat() + "Z"
|
|||
|
|
tx_nonce = os.urandom(16).hex()
|
|||
|
|
tx_id = hashlib.sha256(f"{from_addr}{to_addr}{amount}{timestamp}{tx_nonce}".encode()).hexdigest()
|
|||
|
|
|
|||
|
|
# [FIX R5] Use EventLedger as primary (atomic, skip_balance_check=False),
|
|||
|
|
# wallets.json as fallback — mirrors /api/transfer pattern
|
|||
|
|
if EVENT_LEDGER_AVAILABLE:
|
|||
|
|
try:
|
|||
|
|
ledger = get_event_ledger()
|
|||
|
|
ok, msg, event = ledger.transfer(from_addr, to_addr, amount, metadata={
|
|||
|
|
"tx_id": tx_id,
|
|||
|
|
"type": "agent_transfer",
|
|||
|
|
"agent_name": agent_name,
|
|||
|
|
"pq_signed": False
|
|||
|
|
}, skip_balance_check=False)
|
|||
|
|
if not ok:
|
|||
|
|
return jsonify({"error": "TRANSFER_REJECTED", "message": msg}), 400
|
|||
|
|
if event:
|
|||
|
|
_push_event_to_peers(event.to_dict())
|
|||
|
|
except Exception as e:
|
|||
|
|
log.error(f"Agent transfer ledger error: {e}")
|
|||
|
|
return jsonify({"error": "LEDGER_ERROR", "message": str(e)}), 500
|
|||
|
|
else:
|
|||
|
|
# Fallback: wallets.json atomic transfer
|
|||
|
|
with _wallet_lock:
|
|||
|
|
wallets = load_wallets()
|
|||
|
|
sender_bal = wallets.get(from_addr, {}).get('balance', 0)
|
|||
|
|
if sender_bal < amount:
|
|||
|
|
return jsonify({"error": "INSUFFICIENT_FUNDS",
|
|||
|
|
"message": f"Balance: {int(sender_bal)} Ɉ, required: {amount} Ɉ"}), 400
|
|||
|
|
|
|||
|
|
wallets[from_addr]['balance'] = sender_bal - amount
|
|||
|
|
if to_addr not in wallets:
|
|||
|
|
wallets[to_addr] = {'balance': 0, 'type': 'p2p_sync'}
|
|||
|
|
wallets[to_addr]['balance'] = wallets.get(to_addr, {}).get('balance', 0) + amount
|
|||
|
|
wallets[from_addr]['last_seen'] = datetime.utcnow().isoformat()
|
|||
|
|
save_wallets(wallets)
|
|||
|
|
|
|||
|
|
return jsonify({
|
|||
|
|
"status": "success",
|
|||
|
|
"tx_id": tx_id,
|
|||
|
|
"from": from_addr,
|
|||
|
|
"to": to_addr,
|
|||
|
|
"amount": amount,
|
|||
|
|
"sender_balance": int(get_balance(from_addr)),
|
|||
|
|
"timestamp": timestamp
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.route('/api/wallet/lookup/<identifier>')
|
|||
|
|
def api_wallet_lookup(identifier):
|
|||
|
|
"""
|
|||
|
|
Lookup wallet by any format.
|
|||
|
|
|
|||
|
|
GET /api/wallet/lookup/1 -> Find by number
|
|||
|
|
GET /api/wallet/lookup/Ɉ-1 -> Find by Ɉ-number
|
|||
|
|
GET /api/wallet/lookup/@nickname -> Find by @alias
|
|||
|
|
GET /api/wallet/lookup/abc123 -> Find by hash prefix
|
|||
|
|
"""
|
|||
|
|
registry = load_registry()
|
|||
|
|
aliases = registry.get("aliases", {})
|
|||
|
|
crypto_hash = None
|
|||
|
|
number = None
|
|||
|
|
|
|||
|
|
# @alias lookup
|
|||
|
|
if identifier.startswith('@'):
|
|||
|
|
alias_lower = identifier.lower()
|
|||
|
|
crypto_hash = aliases.get(alias_lower)
|
|||
|
|
if not crypto_hash:
|
|||
|
|
return jsonify({"error": "NOT_FOUND"}), 404
|
|||
|
|
wallet_data = registry["wallets"].get(crypto_hash, {})
|
|||
|
|
number = wallet_data.get("number")
|
|||
|
|
|
|||
|
|
# Number lookup
|
|||
|
|
elif identifier.isdigit():
|
|||
|
|
number = int(identifier)
|
|||
|
|
|
|||
|
|
# Ɉ-N lookup
|
|||
|
|
elif identifier.startswith('Ɉ-'):
|
|||
|
|
try:
|
|||
|
|
number = int(identifier[2:].split('-')[0])
|
|||
|
|
except (ValueError, IndexError):
|
|||
|
|
return jsonify({"error": "INVALID_FORMAT"}), 400
|
|||
|
|
|
|||
|
|
# Hash prefix lookup
|
|||
|
|
else:
|
|||
|
|
for h, data in registry["wallets"].items():
|
|||
|
|
if h.startswith(identifier) or identifier in h:
|
|||
|
|
number = data["number"]
|
|||
|
|
crypto_hash = h
|
|||
|
|
break
|
|||
|
|
if number is None:
|
|||
|
|
return jsonify({"error": "NOT_FOUND"}), 404
|
|||
|
|
|
|||
|
|
# Find by number
|
|||
|
|
if number and not crypto_hash:
|
|||
|
|
for h, data in registry["wallets"].items():
|
|||
|
|
if data["number"] == number:
|
|||
|
|
crypto_hash = h
|
|||
|
|
break
|
|||
|
|
|
|||
|
|
if not crypto_hash:
|
|||
|
|
return jsonify({"error": "NOT_FOUND"}), 404
|
|||
|
|
|
|||
|
|
wallet_data = registry["wallets"].get(crypto_hash, {})
|
|||
|
|
num = wallet_data.get("number", number)
|
|||
|
|
custom_alias = wallet_data.get("custom_alias", "")
|
|||
|
|
|
|||
|
|
return jsonify({
|
|||
|
|
"number": num,
|
|||
|
|
"alias": f"Ɉ-{num}",
|
|||
|
|
"custom_alias": custom_alias,
|
|||
|
|
"address": f"Ɉ-{num}-{crypto_hash}",
|
|||
|
|
"crypto_hash": crypto_hash,
|
|||
|
|
"registered_at": wallet_data.get("registered_at")
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# TIMECHAIN ENDPOINTS
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
# Singleton: инициализируется один раз при первом вызове
|
|||
|
|
_timechain_instance = None
|
|||
|
|
_timechain_lock = threading.Lock()
|
|||
|
|
|
|||
|
|
def get_timechain():
|
|||
|
|
"""
|
|||
|
|
Lazy singleton TimeChain.
|
|||
|
|
Auto-generates node keys if not exist (stored in data/timechain_keys.json).
|
|||
|
|
"""
|
|||
|
|
global _timechain_instance
|
|||
|
|
if _timechain_instance is not None:
|
|||
|
|
return _timechain_instance
|
|||
|
|
|
|||
|
|
with _timechain_lock:
|
|||
|
|
if _timechain_instance is not None:
|
|||
|
|
return _timechain_instance
|
|||
|
|
|
|||
|
|
from timechain import TimeChain
|
|||
|
|
from node_crypto import generate_keypair, public_key_to_address
|
|||
|
|
|
|||
|
|
keys_file = DATA_DIR / "timechain_keys.json"
|
|||
|
|
db_path = str(DATA_DIR / "timechain.db")
|
|||
|
|
|
|||
|
|
if keys_file.exists():
|
|||
|
|
with open(keys_file) as f:
|
|||
|
|
keys = json.load(f)
|
|||
|
|
node_id = keys["node_id"]
|
|||
|
|
private_key = keys["private_key"]
|
|||
|
|
public_key = keys["public_key"]
|
|||
|
|
else:
|
|||
|
|
# Auto-generate ML-DSA-65 keys for this node
|
|||
|
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|||
|
|
private_key, public_key = generate_keypair()
|
|||
|
|
node_id = public_key_to_address(public_key)
|
|||
|
|
keys = {
|
|||
|
|
"node_id": node_id,
|
|||
|
|
"private_key": private_key,
|
|||
|
|
"public_key": public_key,
|
|||
|
|
"generated_at": datetime.utcnow().isoformat() + "Z",
|
|||
|
|
}
|
|||
|
|
with open(keys_file, "w") as f:
|
|||
|
|
json.dump(keys, f, indent=2)
|
|||
|
|
# Secure: restrict file permissions
|
|||
|
|
import stat
|
|||
|
|
keys_file.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|||
|
|
log.info(f"TimeChain: generated keys, node_id={node_id}")
|
|||
|
|
|
|||
|
|
tc = TimeChain(
|
|||
|
|
node_id=node_id,
|
|||
|
|
private_key=private_key,
|
|||
|
|
public_key=public_key,
|
|||
|
|
db_path=db_path,
|
|||
|
|
)
|
|||
|
|
_timechain_instance = tc
|
|||
|
|
log.info(f"TimeChain initialized: τ₁={tc.tau1_count}, τ₂={tc.tau2_count}")
|
|||
|
|
return tc
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.route('/api/timechain/stats')
|
|||
|
|
def api_timechain_stats():
|
|||
|
|
"""TimeChain statistics — window counts, supply, TIME_BANK"""
|
|||
|
|
try:
|
|||
|
|
tc = get_timechain()
|
|||
|
|
stats = tc.get_stats()
|
|||
|
|
# Add verification status
|
|||
|
|
ok, msg = tc.verify_full_chain()
|
|||
|
|
stats["verified"] = ok
|
|||
|
|
stats["verify_message"] = msg
|
|||
|
|
stats["node_id"] = tc.node_id
|
|||
|
|
return jsonify(stats)
|
|||
|
|
except Exception as e:
|
|||
|
|
return jsonify({
|
|||
|
|
"error": "TIMECHAIN_ERROR",
|
|||
|
|
"message": str(e)
|
|||
|
|
}), 500
|
|||
|
|
|
|||
|
|
@app.route('/api/timechain/windows')
|
|||
|
|
def api_timechain_windows():
|
|||
|
|
"""Get recent TimeChain windows (τ₁, τ₂, τ₃, τ₄)"""
|
|||
|
|
try:
|
|||
|
|
tc = get_timechain()
|
|||
|
|
limit = request.args.get('limit', 20, type=int)
|
|||
|
|
limit = min(limit, 100)
|
|||
|
|
layer = request.args.get('layer', 'tau1')
|
|||
|
|
|
|||
|
|
if layer == 'tau1':
|
|||
|
|
windows = tc.get_recent_tau1_windows(limit=limit)
|
|||
|
|
elif layer == 'tau2':
|
|||
|
|
windows = tc.get_recent_tau2_windows(limit=limit)
|
|||
|
|
elif layer == 'tau3':
|
|||
|
|
windows = tc.get_recent_tau3_windows(limit=limit)
|
|||
|
|
elif layer == 'tau4':
|
|||
|
|
windows = tc.get_recent_tau4_windows(limit=limit)
|
|||
|
|
else:
|
|||
|
|
return jsonify({"error": "INVALID_LAYER", "message": "Use: tau1, tau2, tau3, tau4"}), 400
|
|||
|
|
|
|||
|
|
return jsonify({
|
|||
|
|
"windows": windows,
|
|||
|
|
"layer": layer,
|
|||
|
|
"count": len(windows),
|
|||
|
|
})
|
|||
|
|
except Exception as e:
|
|||
|
|
return jsonify({
|
|||
|
|
"error": "TIMECHAIN_ERROR",
|
|||
|
|
"message": str(e)
|
|||
|
|
}), 500
|
|||
|
|
|
|||
|
|
@app.route('/api/timechain/verify')
|
|||
|
|
@rate_limit(limit=2, window=60) # [FIX R7 CWE-400] CPU-heavy — 2 req/min
|
|||
|
|
def api_timechain_verify():
|
|||
|
|
"""Full chain verification — horizontal + vertical + signatures + UTXO"""
|
|||
|
|
try:
|
|||
|
|
tc = get_timechain()
|
|||
|
|
ok, msg = tc.verify_full_chain()
|
|||
|
|
return jsonify({
|
|||
|
|
"verified": ok,
|
|||
|
|
"message": msg,
|
|||
|
|
"tau1_count": tc.tau1_count,
|
|||
|
|
"tau2_count": tc.tau2_count,
|
|||
|
|
"tau3_count": tc.tau3_count,
|
|||
|
|
"tau4_count": tc.tau4_count,
|
|||
|
|
})
|
|||
|
|
except Exception as e:
|
|||
|
|
return jsonify({
|
|||
|
|
"error": "TIMECHAIN_ERROR",
|
|||
|
|
"message": str(e)
|
|||
|
|
}), 500
|
|||
|
|
|
|||
|
|
@app.route('/api/timechain/balances')
|
|||
|
|
@rate_limit(limit=5, window=60) # [FIX R7 CWE-400] CPU-heavy — 5 req/min
|
|||
|
|
def api_timechain_balances():
|
|||
|
|
"""All UTXO-based balances"""
|
|||
|
|
try:
|
|||
|
|
tc = get_timechain()
|
|||
|
|
balances = tc.get_all_balances()
|
|||
|
|
return jsonify({
|
|||
|
|
"balances": balances,
|
|||
|
|
"total_supply": tc.utxo_set.total_unspent(),
|
|||
|
|
"count": len(balances),
|
|||
|
|
})
|
|||
|
|
except Exception as e:
|
|||
|
|
return jsonify({
|
|||
|
|
"error": "TIMECHAIN_ERROR",
|
|||
|
|
"message": str(e)
|
|||
|
|
}), 500
|
|||
|
|
|
|||
|
|
@app.route('/api/timechain/balance/<address>')
|
|||
|
|
def api_timechain_balance(address: str):
|
|||
|
|
"""UTXO balance for specific address"""
|
|||
|
|
if not _validate_address(address):
|
|||
|
|
return jsonify({"error": "INVALID_ADDRESS"}), 400
|
|||
|
|
try:
|
|||
|
|
tc = get_timechain()
|
|||
|
|
return jsonify(tc.get_balance(address))
|
|||
|
|
except Exception as e:
|
|||
|
|
return jsonify({
|
|||
|
|
"error": "TIMECHAIN_ERROR",
|
|||
|
|
"message": str(e)
|
|||
|
|
}), 500
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# NTS ANCHOR ENDPOINTS
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
@app.route('/api/nts/servers')
|
|||
|
|
def api_nts_servers():
|
|||
|
|
"""List all 36 NTS servers with region grouping"""
|
|||
|
|
try:
|
|||
|
|
from nts_anchor import get_nts_server_list, get_nts_region_summary, REGION_NAMES
|
|||
|
|
servers = get_nts_server_list()
|
|||
|
|
regions = get_nts_region_summary()
|
|||
|
|
return jsonify({
|
|||
|
|
"servers": servers,
|
|||
|
|
"total": len(servers),
|
|||
|
|
"regions": {k: {"count": v, "name": REGION_NAMES.get(k, k)} for k, v in regions.items()},
|
|||
|
|
})
|
|||
|
|
except Exception as e:
|
|||
|
|
return jsonify({"error": "NTS_ERROR", "message": str(e)}), 500
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.route('/api/nts/status')
|
|||
|
|
def api_nts_status():
|
|||
|
|
"""NTS anchor status — latest τ₂ anchor + overall statistics"""
|
|||
|
|
try:
|
|||
|
|
tc = get_timechain()
|
|||
|
|
anchor_count = tc.db.get_nts_anchor_count("tau2")
|
|||
|
|
|
|||
|
|
# Get latest anchor if exists
|
|||
|
|
latest_anchor = None
|
|||
|
|
if tc.tau2_count > 0:
|
|||
|
|
anchor = tc.db.get_nts_anchor("tau2", tc.tau2_count - 1)
|
|||
|
|
if anchor:
|
|||
|
|
latest_anchor = anchor.to_dict()
|
|||
|
|
|
|||
|
|
return jsonify({
|
|||
|
|
"nts_enabled": tc.nts_service is not None,
|
|||
|
|
"total_anchors": anchor_count,
|
|||
|
|
"tau2_count": tc.tau2_count,
|
|||
|
|
"coverage": f"{anchor_count}/{tc.tau2_count}" if tc.tau2_count > 0 else "0/0",
|
|||
|
|
"latest_anchor": latest_anchor,
|
|||
|
|
})
|
|||
|
|
except Exception as e:
|
|||
|
|
return jsonify({"error": "NTS_ERROR", "message": str(e)}), 500
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.route('/api/nts/anchor/<int:tau2_number>')
|
|||
|
|
def api_nts_anchor(tau2_number):
|
|||
|
|
"""NTS anchor details for a specific τ₂ window"""
|
|||
|
|
try:
|
|||
|
|
tc = get_timechain()
|
|||
|
|
anchor = tc.db.get_nts_anchor("tau2", tau2_number)
|
|||
|
|
if not anchor:
|
|||
|
|
return jsonify({"error": "NOT_FOUND", "message": f"No NTS anchor for τ₂ #{tau2_number}"}), 404
|
|||
|
|
|
|||
|
|
# Verify if service available
|
|||
|
|
verified = None
|
|||
|
|
if tc.nts_service:
|
|||
|
|
ok, msg = tc.nts_service.verify_anchor(anchor)
|
|||
|
|
verified = {"ok": ok, "message": msg}
|
|||
|
|
|
|||
|
|
return jsonify({
|
|||
|
|
"anchor": anchor.to_dict(),
|
|||
|
|
"verified": verified,
|
|||
|
|
})
|
|||
|
|
except Exception as e:
|
|||
|
|
return jsonify({"error": "NTS_ERROR", "message": str(e)}), 500
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# TIME BANK ENDPOINTS
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
@app.route('/api/timebank/stats')
|
|||
|
|
def api_timebank_stats():
|
|||
|
|
"""Time Bank statistics"""
|
|||
|
|
try:
|
|||
|
|
from time_bank import TimeBank
|
|||
|
|
tb = TimeBank()
|
|||
|
|
return jsonify(tb.get_stats())
|
|||
|
|
except Exception as e:
|
|||
|
|
return jsonify({
|
|||
|
|
"error": "TIMEBANK_ERROR",
|
|||
|
|
"message": str(e)
|
|||
|
|
}), 500
|
|||
|
|
|
|||
|
|
@app.route('/api/timebank/activity', methods=['POST'])
|
|||
|
|
@rate_limit(limit=60, window=60)
|
|||
|
|
def api_timebank_activity():
|
|||
|
|
"""
|
|||
|
|
Record presence activity for Time Bank emission.
|
|||
|
|
|
|||
|
|
Required fields:
|
|||
|
|
- address: Montana address (mt...)
|
|||
|
|
- signature: proof of presence signature
|
|||
|
|
- public_key: signer's public key
|
|||
|
|
"""
|
|||
|
|
data = request.get_json()
|
|||
|
|
|
|||
|
|
if not data:
|
|||
|
|
return jsonify({"error": "NO_DATA"}), 400
|
|||
|
|
|
|||
|
|
address = data.get('address')
|
|||
|
|
signature = data.get('signature')
|
|||
|
|
public_key = data.get('public_key')
|
|||
|
|
|
|||
|
|
if not all([address, signature, public_key]):
|
|||
|
|
return jsonify({
|
|||
|
|
"error": "MISSING_FIELDS",
|
|||
|
|
"message": "Required: address, signature, public_key"
|
|||
|
|
}), 400
|
|||
|
|
|
|||
|
|
# Verify address matches public key
|
|||
|
|
derived = public_key_to_address(public_key)
|
|||
|
|
if derived != address:
|
|||
|
|
return jsonify({"error": "ADDRESS_MISMATCH"}), 403
|
|||
|
|
|
|||
|
|
# [FIX R9 CWE-840] Use client-supplied timestamp with freshness check
|
|||
|
|
client_timestamp = data.get('timestamp', '')
|
|||
|
|
if not client_timestamp:
|
|||
|
|
return jsonify({"error": "MISSING_TIMESTAMP", "message": "timestamp required"}), 400
|
|||
|
|
# Verify freshness — must be within 5 minutes
|
|||
|
|
try:
|
|||
|
|
client_dt = datetime.fromisoformat(client_timestamp.replace('Z', '+00:00'))
|
|||
|
|
server_dt = datetime.utcnow()
|
|||
|
|
# Strip tzinfo for comparison (both are UTC)
|
|||
|
|
if client_dt.tzinfo:
|
|||
|
|
from datetime import timezone
|
|||
|
|
server_dt = server_dt.replace(tzinfo=timezone.utc)
|
|||
|
|
drift = abs((server_dt - client_dt).total_seconds())
|
|||
|
|
if drift > 300:
|
|||
|
|
return jsonify({"error": "TIMESTAMP_EXPIRED", "message": "Timestamp drift > 5 min"}), 403
|
|||
|
|
except (ValueError, TypeError):
|
|||
|
|
return jsonify({"error": "INVALID_TIMESTAMP"}), 400
|
|||
|
|
|
|||
|
|
message = f"PRESENCE:{address}:{client_timestamp[:16]}" # Minute precision
|
|||
|
|
if not verify_signature(public_key, message, signature):
|
|||
|
|
return jsonify({"error": "INVALID_SIGNATURE"}), 403
|
|||
|
|
|
|||
|
|
# Record activity
|
|||
|
|
try:
|
|||
|
|
from time_bank import TimeBank
|
|||
|
|
tb = TimeBank()
|
|||
|
|
result = tb.activity(address)
|
|||
|
|
return jsonify(result)
|
|||
|
|
except Exception as e:
|
|||
|
|
return jsonify({"error": "TIMEBANK_ERROR", "message": str(e)}), 500
|
|||
|
|
|
|||
|
|
def _verify_pq_signature(req, message=None):
|
|||
|
|
"""
|
|||
|
|
Verify ML-DSA-65 (FIPS 204) post-quantum signature from request headers.
|
|||
|
|
Headers: X-Address, X-Timestamp, X-Signature, X-Public-Key, X-Signature-Algorithm
|
|||
|
|
Returns (address, verified) tuple.
|
|||
|
|
|
|||
|
|
If message is provided, full signature verification is performed.
|
|||
|
|
Otherwise, only key→address ownership is checked.
|
|||
|
|
"""
|
|||
|
|
sig_algo = req.headers.get('X-Signature-Algorithm', '')
|
|||
|
|
address = req.headers.get('X-Address', '') or req.headers.get('X-Device-ID', '')
|
|||
|
|
timestamp = req.headers.get('X-Timestamp', '')
|
|||
|
|
sig_hex = req.headers.get('X-Signature', '')
|
|||
|
|
pub_hex = req.headers.get('X-Public-Key', '')
|
|||
|
|
|
|||
|
|
if not address or not _validate_address(address):
|
|||
|
|
return address, False
|
|||
|
|
|
|||
|
|
# [FIX CWE-306] Unsigned requests are NOT verified — return False
|
|||
|
|
if not sig_hex or not pub_hex:
|
|||
|
|
log.warning("Unsigned request from %s — not verified", address)
|
|||
|
|
return address, False
|
|||
|
|
|
|||
|
|
if sig_algo and sig_algo != 'ML-DSA-65':
|
|||
|
|
return address, False
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
# Verify public key matches claimed address (pub_hex is hex string)
|
|||
|
|
derived = public_key_to_address(pub_hex)
|
|||
|
|
if derived != address:
|
|||
|
|
log.warning(f"PQ: address mismatch {address} vs derived {derived}")
|
|||
|
|
return address, False
|
|||
|
|
|
|||
|
|
# Full ML-DSA-65 signature verification — message REQUIRED
|
|||
|
|
if not message:
|
|||
|
|
# [FIX CWE-306 R5] No message = cannot verify signature — reject
|
|||
|
|
log.warning(f"PQ: no message to verify for {address} — rejecting")
|
|||
|
|
return address, False
|
|||
|
|
|
|||
|
|
if verify_signature(pub_hex, message, sig_hex):
|
|||
|
|
log.info(f"PQ: ML-DSA-65 verified for {address}")
|
|||
|
|
return address, True
|
|||
|
|
else:
|
|||
|
|
# [FIX CWE-306 R5] Signature FAILED = NOT verified — no backward compat
|
|||
|
|
log.warning(f"PQ: ML-DSA-65 signature FAILED for {address}")
|
|||
|
|
return address, False
|
|||
|
|
except Exception as e:
|
|||
|
|
# [FIX CWE-306 R5] Exception during verification = NOT verified
|
|||
|
|
log.warning(f"PQ verification error: {e}")
|
|||
|
|
return address, False
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.route('/api/presence', methods=['POST'])
|
|||
|
|
@rate_limit(limit=60, window=60)
|
|||
|
|
def api_presence():
|
|||
|
|
"""
|
|||
|
|
Lightweight presence heartbeat for iOS/macOS apps.
|
|||
|
|
Post-quantum: ML-DSA-65 signed (FIPS 204).
|
|||
|
|
|
|||
|
|
Headers: X-Address, X-Timestamp, X-Signature (ML-DSA-65), X-Public-Key
|
|||
|
|
Body: {"seconds": N} — seconds of presence since last report
|
|||
|
|
Returns: {"balance": N}
|
|||
|
|
|
|||
|
|
Client signs: "PRESENCE:{address}:{seconds}:{timestamp}"
|
|||
|
|
"""
|
|||
|
|
data = request.get_json() or {}
|
|||
|
|
seconds = int(data.get('seconds', 0))
|
|||
|
|
|
|||
|
|
# Reconstruct message for ML-DSA-65 verification
|
|||
|
|
address_hdr = request.headers.get('X-Address', '')
|
|||
|
|
timestamp_hdr = request.headers.get('X-Timestamp', '')
|
|||
|
|
pq_message = f"PRESENCE:{address_hdr}:{seconds}:{timestamp_hdr}" if address_hdr and timestamp_hdr else None
|
|||
|
|
|
|||
|
|
address, verified = _verify_pq_signature(request, message=pq_message)
|
|||
|
|
|
|||
|
|
# [FIX R7 CWE-306] Enforce ML-DSA-65 signature — unsigned presence = coin minting attack
|
|||
|
|
if not verified:
|
|||
|
|
# Allow localhost (for internal tooling) but reject external unsigned
|
|||
|
|
client_ip = _get_client_ip()
|
|||
|
|
if client_ip not in ('127.0.0.1', '::1'):
|
|||
|
|
return jsonify({"error": "SIGNATURE_REQUIRED",
|
|||
|
|
"message": "Presence requires ML-DSA-65 signature"}), 403
|
|||
|
|
|
|||
|
|
# Backward compat: also check old header (only for address, not auth)
|
|||
|
|
if not address:
|
|||
|
|
address = request.headers.get('X-Device-ID', '')
|
|||
|
|
|
|||
|
|
if not _validate_address(address):
|
|||
|
|
return jsonify({"error": "INVALID_ADDRESS"}), 400
|
|||
|
|
|
|||
|
|
if seconds < 0 or seconds > 600: # [FIX R7] max 600s per call (τ₂ window)
|
|||
|
|
return jsonify({"error": "INVALID_SECONDS"}), 400
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
# ── Write to shared presence queue for timechain_node ──
|
|||
|
|
# Node reads this at τ₂ boundary to create coinbase emissions
|
|||
|
|
if seconds > 0:
|
|||
|
|
_queue_presence(address, seconds)
|
|||
|
|
|
|||
|
|
# Register activity in TIME_BANK (legacy)
|
|||
|
|
if seconds > 0 and TIME_BANK_AVAILABLE:
|
|||
|
|
try:
|
|||
|
|
bank = get_time_bank()
|
|||
|
|
bank.activity(address, addr_type="presence_app")
|
|||
|
|
except Exception as e:
|
|||
|
|
log.warning(f"TIME_BANK activity error: {e}")
|
|||
|
|
|
|||
|
|
# Get balance from TimeChain UTXO
|
|||
|
|
try:
|
|||
|
|
from timechain import TimeChain
|
|||
|
|
tc = TimeChain(
|
|||
|
|
db_path=str(Path(__file__).parent / "data" / "timechain.db"),
|
|||
|
|
node_id=address,
|
|||
|
|
)
|
|||
|
|
balance = tc.utxo_set.get_balance(address)
|
|||
|
|
except Exception:
|
|||
|
|
balance = 0
|
|||
|
|
|
|||
|
|
return jsonify({"balance": int(balance), "pq_verified": verified})
|
|||
|
|
except Exception as e:
|
|||
|
|
return jsonify({"error": "PRESENCE_ERROR", "message": str(e)}), 500
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# VERSION / AUTO-UPDATE
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
# Current app versions — update these on each release
|
|||
|
|
APP_VERSIONS = {
|
|||
|
|
"macos": {
|
|||
|
|
"version": "3.17.0",
|
|||
|
|
"build": 129,
|
|||
|
|
"url": "https://efir.org/downloads/Montana.app.zip",
|
|||
|
|
"notes": "NTS Anchor: 36 global atomic time servers (RFC 8915) — chain recalculation impossible",
|
|||
|
|
"sha256": ""
|
|||
|
|
},
|
|||
|
|
"ios": {
|
|||
|
|
"version": "2.1.0",
|
|||
|
|
"build": 23,
|
|||
|
|
"url": "https://efir.org/downloads/Montana_2.1.0.ipa",
|
|||
|
|
"notes": "Full independence, standalone Montana Protocol"
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# Urgent update flag — when True, all clients switch to T1 mode (check every 60s)
|
|||
|
|
_urgent_update = {"active": False, "activated_at": None}
|
|||
|
|
_urgent_lock = threading.Lock()
|
|||
|
|
|
|||
|
|
# Admin key for urgent updates (stored on server, NOT in code)
|
|||
|
|
ADMIN_KEY = os.environ.get("MONTANA_ADMIN_KEY", "")
|
|||
|
|
|
|||
|
|
# Urgent auto-deactivation timeout (1 hour)
|
|||
|
|
URGENT_TIMEOUT_SECONDS = 3600
|
|||
|
|
|
|||
|
|
@app.route('/api/version/<platform>')
|
|||
|
|
@rate_limit(limit=60, window=60)
|
|||
|
|
def api_version(platform: str):
|
|||
|
|
"""
|
|||
|
|
Check latest app version for auto-update.
|
|||
|
|
|
|||
|
|
GET /api/version/macos → {"version": "1.1.0", "build": 2, "url": "..."}
|
|||
|
|
GET /api/version/ios → {"version": "2.0.0", "build": 21, "url": "..."}
|
|||
|
|
"""
|
|||
|
|
platform = platform.lower().strip()
|
|||
|
|
if platform not in APP_VERSIONS:
|
|||
|
|
return jsonify({"error": "UNKNOWN_PLATFORM",
|
|||
|
|
"message": f"Supported: {', '.join(APP_VERSIONS.keys())}"}), 400
|
|||
|
|
|
|||
|
|
# Auto-deactivate urgent mode after timeout
|
|||
|
|
with _urgent_lock:
|
|||
|
|
if _urgent_update["active"] and _urgent_update["activated_at"]:
|
|||
|
|
try:
|
|||
|
|
activated = datetime.fromisoformat(_urgent_update["activated_at"])
|
|||
|
|
if (datetime.utcnow() - activated).total_seconds() > URGENT_TIMEOUT_SECONDS:
|
|||
|
|
_urgent_update["active"] = False
|
|||
|
|
_urgent_update["activated_at"] = None
|
|||
|
|
except (ValueError, TypeError):
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
info = APP_VERSIONS[platform]
|
|||
|
|
with _urgent_lock:
|
|||
|
|
urgent = _urgent_update["active"]
|
|||
|
|
|
|||
|
|
return jsonify({
|
|||
|
|
"version": info["version"],
|
|||
|
|
"build": info["build"],
|
|||
|
|
"url": info["url"],
|
|||
|
|
"notes": info["notes"],
|
|||
|
|
"sha256": info.get("sha256", ""),
|
|||
|
|
"urgent": urgent,
|
|||
|
|
"timestamp": datetime.utcnow().isoformat() + "Z"
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.route('/api/update/urgent', methods=['POST'])
|
|||
|
|
@rate_limit(limit=5, window=60)
|
|||
|
|
def api_update_urgent():
|
|||
|
|
"""
|
|||
|
|
Activate/deactivate urgent update mode (T1 = 60s check interval).
|
|||
|
|
REQUIRES X-Admin-Key header (stored in server env as MONTANA_ADMIN_KEY).
|
|||
|
|
|
|||
|
|
POST /api/update/urgent
|
|||
|
|
Headers: X-Admin-Key: <key>
|
|||
|
|
Body: {"active": true, "version": "3.7.6", "url": "...", "notes": "...", "sha256": "..."}
|
|||
|
|
"""
|
|||
|
|
# Authentication: require admin key
|
|||
|
|
admin_key = request.headers.get("X-Admin-Key", "")
|
|||
|
|
if not ADMIN_KEY or not hmac.compare_digest(admin_key, ADMIN_KEY):
|
|||
|
|
return jsonify({"error": "UNAUTHORIZED"}), 403
|
|||
|
|
|
|||
|
|
data = request.get_json()
|
|||
|
|
if not data:
|
|||
|
|
return jsonify({"error": "JSON_REQUIRED"}), 400
|
|||
|
|
|
|||
|
|
with _urgent_lock:
|
|||
|
|
# Update urgent flag
|
|||
|
|
if "active" in data:
|
|||
|
|
_urgent_update["active"] = bool(data["active"])
|
|||
|
|
_urgent_update["activated_at"] = datetime.utcnow().isoformat() if data["active"] else None
|
|||
|
|
|
|||
|
|
# Optionally update version info at the same time
|
|||
|
|
if "version" in data and "macos" in APP_VERSIONS:
|
|||
|
|
APP_VERSIONS["macos"]["version"] = data["version"]
|
|||
|
|
if "build" in data and "macos" in APP_VERSIONS:
|
|||
|
|
APP_VERSIONS["macos"]["build"] = int(data["build"])
|
|||
|
|
if "url" in data and "macos" in APP_VERSIONS:
|
|||
|
|
APP_VERSIONS["macos"]["url"] = data["url"]
|
|||
|
|
if "notes" in data and "macos" in APP_VERSIONS:
|
|||
|
|
APP_VERSIONS["macos"]["notes"] = data["notes"]
|
|||
|
|
if "sha256" in data and "macos" in APP_VERSIONS:
|
|||
|
|
APP_VERSIONS["macos"]["sha256"] = data["sha256"]
|
|||
|
|
|
|||
|
|
with _urgent_lock:
|
|||
|
|
return jsonify({
|
|||
|
|
"status": "ok",
|
|||
|
|
"urgent": _urgent_update["active"],
|
|||
|
|
"activated_at": _urgent_update["activated_at"],
|
|||
|
|
"current_version": APP_VERSIONS.get("macos", {})
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# ACTIVE ADDRESSES / NETWORK VIEW
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
@app.route('/api/addresses')
|
|||
|
|
@rate_limit(limit=60, window=60)
|
|||
|
|
def api_addresses():
|
|||
|
|
"""
|
|||
|
|
List all active addresses with types — for Mac app network view.
|
|||
|
|
|
|||
|
|
GET /api/addresses
|
|||
|
|
Returns: {
|
|||
|
|
"addresses": [
|
|||
|
|
{"address": "mt...", "balance": N, "type": "ai_agent"|"human"|"presence_app", ...}
|
|||
|
|
],
|
|||
|
|
"total": N,
|
|||
|
|
"agents": N,
|
|||
|
|
"humans": N
|
|||
|
|
}
|
|||
|
|
"""
|
|||
|
|
wallets = load_wallets()
|
|||
|
|
registry = load_registry()
|
|||
|
|
aliases = registry.get("aliases", {})
|
|||
|
|
|
|||
|
|
addresses = []
|
|||
|
|
agent_count = 0
|
|||
|
|
human_count = 0
|
|||
|
|
|
|||
|
|
for addr, data in wallets.items():
|
|||
|
|
if not addr.startswith("mt"):
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
addr_type = data.get("type", "unknown")
|
|||
|
|
agent_name = data.get("agent_name", "")
|
|||
|
|
balance = data.get("balance", 0)
|
|||
|
|
|
|||
|
|
# Determine type
|
|||
|
|
if addr_type == "ai_agent" or agent_name:
|
|||
|
|
display_type = "ai_agent"
|
|||
|
|
agent_count += 1
|
|||
|
|
elif addr_type in ("presence_app", "p2p_sync") or balance > 0:
|
|||
|
|
display_type = "human"
|
|||
|
|
human_count += 1
|
|||
|
|
else:
|
|||
|
|
display_type = "unknown"
|
|||
|
|
|
|||
|
|
# Find alias
|
|||
|
|
crypto_hash = addr[2:]
|
|||
|
|
wallet_reg = registry.get("wallets", {}).get(crypto_hash, {})
|
|||
|
|
number = wallet_reg.get("number")
|
|||
|
|
custom_alias = wallet_reg.get("custom_alias", "")
|
|||
|
|
|
|||
|
|
entry = {
|
|||
|
|
"address": addr,
|
|||
|
|
"balance": int(balance),
|
|||
|
|
"type": display_type,
|
|||
|
|
"alias": f"Ɉ-{number}" if number else "",
|
|||
|
|
"custom_alias": custom_alias,
|
|||
|
|
"agent_name": agent_name,
|
|||
|
|
"last_seen": data.get("last_seen", data.get("updated_at", "")),
|
|||
|
|
}
|
|||
|
|
addresses.append(entry)
|
|||
|
|
|
|||
|
|
# Sort by balance descending
|
|||
|
|
addresses.sort(key=lambda x: x["balance"], reverse=True)
|
|||
|
|
|
|||
|
|
return jsonify({
|
|||
|
|
"addresses": addresses,
|
|||
|
|
"total": len(addresses),
|
|||
|
|
"agents": agent_count,
|
|||
|
|
"humans": human_count,
|
|||
|
|
"node_id": NODE_ID,
|
|||
|
|
"timestamp": datetime.utcnow().isoformat() + "Z"
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# P2P NODE SYNC
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
@app.route('/api/node/events')
|
|||
|
|
@rate_limit(limit=60, window=60)
|
|||
|
|
def api_node_events():
|
|||
|
|
"""
|
|||
|
|
Get events since a given event_id — for P2P pull-based sync.
|
|||
|
|
|
|||
|
|
GET /api/node/events?since=<event_id>&limit=1000
|
|||
|
|
Returns: {"events": [...], "node_id": "...", "count": N}
|
|||
|
|
"""
|
|||
|
|
if not EVENT_LEDGER_AVAILABLE:
|
|||
|
|
return jsonify({"error": "EVENT_LEDGER_UNAVAILABLE"}), 503
|
|||
|
|
|
|||
|
|
since = request.args.get('since', '')
|
|||
|
|
address = request.args.get('address', '')
|
|||
|
|
limit = min(int(request.args.get('limit', 1000)), 5000)
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
ledger = get_event_ledger()
|
|||
|
|
if address:
|
|||
|
|
# Per-address filtering (for История / History view)
|
|||
|
|
events = ledger.get_events(address=address, limit=limit)
|
|||
|
|
event_list = [e.to_dict() for e in events]
|
|||
|
|
elif since:
|
|||
|
|
# Sync mode: events since last known event_id
|
|||
|
|
events = ledger.get_events_since(since)
|
|||
|
|
event_list = [e.to_dict() for e in events[:limit]]
|
|||
|
|
else:
|
|||
|
|
# [FIX] Default: get latest events (newest first, sorted by timestamp_ns)
|
|||
|
|
events = ledger.get_events(limit=limit)
|
|||
|
|
event_list = [e.to_dict() for e in events]
|
|||
|
|
|
|||
|
|
# Enrich events with aliases from registry
|
|||
|
|
registry = load_registry() or {}
|
|||
|
|
addr_to_alias = {}
|
|||
|
|
for crypto_hash, info in registry.get("wallets", {}).items():
|
|||
|
|
addr = f"mt{crypto_hash}"
|
|||
|
|
number = info.get("number", "?")
|
|||
|
|
custom_alias = info.get("custom_alias", "")
|
|||
|
|
display = custom_alias if custom_alias else f"\u0248-{number}"
|
|||
|
|
addr_to_alias[addr] = display
|
|||
|
|
|
|||
|
|
for evt in event_list:
|
|||
|
|
to_addr = evt.get("to_addr", "")
|
|||
|
|
from_addr = evt.get("from_addr", "")
|
|||
|
|
evt["to_alias"] = addr_to_alias.get(to_addr, "")
|
|||
|
|
evt["from_alias"] = addr_to_alias.get(from_addr, "")
|
|||
|
|
# TIME_BANK emissions have no from_addr
|
|||
|
|
if evt.get("event_type") == "EMISSION" and not from_addr:
|
|||
|
|
evt["from_alias"] = "\u0248-0"
|
|||
|
|
|
|||
|
|
return jsonify({
|
|||
|
|
"events": event_list,
|
|||
|
|
"node_id": ledger.node_id,
|
|||
|
|
"count": len(event_list),
|
|||
|
|
"last_hash": ledger._last_hash[:16]
|
|||
|
|
})
|
|||
|
|
except Exception as e:
|
|||
|
|
return jsonify({"error": "SYNC_ERROR", "message": str(e)}), 500
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.route('/api/node/sync', methods=['POST'])
|
|||
|
|
@rate_limit(limit=30, window=60)
|
|||
|
|
def api_node_sync():
|
|||
|
|
"""
|
|||
|
|
Bidirectional P2P sync — the core of Montana network.
|
|||
|
|
|
|||
|
|
POST /api/node/sync
|
|||
|
|
Body: {
|
|||
|
|
"events": [...], # events to push to this node
|
|||
|
|
"last_event_id": "...", # last event the peer knows about
|
|||
|
|
"node_id": "..." # peer's node_id
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Returns: {
|
|||
|
|
"merged": N, # events we accepted from peer
|
|||
|
|
"events": [...], # events the peer doesn't have
|
|||
|
|
"node_id": "...",
|
|||
|
|
"count": N
|
|||
|
|
}
|
|||
|
|
"""
|
|||
|
|
if not EVENT_LEDGER_AVAILABLE:
|
|||
|
|
return jsonify({"error": "EVENT_LEDGER_UNAVAILABLE"}), 503
|
|||
|
|
|
|||
|
|
# [FIX R6 CWE-302] Only accept sync from trusted peers
|
|||
|
|
client_ip = _get_client_ip()
|
|||
|
|
trusted_ips = {'127.0.0.1', '::1'} | {p['ip'] for p in PEER_NODES}
|
|||
|
|
if client_ip not in trusted_ips:
|
|||
|
|
log.warning(f"Node sync rejected from untrusted IP: {client_ip}")
|
|||
|
|
return jsonify({"error": "UNTRUSTED_SOURCE"}), 403
|
|||
|
|
|
|||
|
|
data = request.get_json()
|
|||
|
|
if not data:
|
|||
|
|
return jsonify({"error": "NO_DATA"}), 400
|
|||
|
|
|
|||
|
|
remote_events = data.get('events', [])
|
|||
|
|
last_known_id = data.get('last_event_id', '')
|
|||
|
|
|
|||
|
|
# [FIX R6 CWE-400] Limit incoming events to prevent DoS
|
|||
|
|
MAX_SYNC_EVENTS = 1000
|
|||
|
|
if not isinstance(remote_events, list) or len(remote_events) > MAX_SYNC_EVENTS:
|
|||
|
|
return jsonify({"error": "TOO_MANY_EVENTS",
|
|||
|
|
"message": f"Max {MAX_SYNC_EVENTS} events per sync"}), 400
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
ledger = get_event_ledger()
|
|||
|
|
|
|||
|
|
# Merge incoming events from peer
|
|||
|
|
merged = 0
|
|||
|
|
if remote_events:
|
|||
|
|
merged = ledger.merge_events(remote_events)
|
|||
|
|
if merged > 0:
|
|||
|
|
log.info(f"P2P SYNC: merged {merged} events from {data.get('node_id', '?')}")
|
|||
|
|
|
|||
|
|
# Apply merged events to wallet cache
|
|||
|
|
_apply_ledger_events_to_wallets(remote_events[:merged] if merged <= len(remote_events) else remote_events)
|
|||
|
|
|
|||
|
|
# Return events the peer doesn't have
|
|||
|
|
new_events = ledger.get_events_since(last_known_id)
|
|||
|
|
event_list = [e.to_dict() for e in new_events[:2000]]
|
|||
|
|
|
|||
|
|
return jsonify({
|
|||
|
|
"merged": merged,
|
|||
|
|
"events": event_list,
|
|||
|
|
"node_id": ledger.node_id,
|
|||
|
|
"count": len(event_list)
|
|||
|
|
})
|
|||
|
|
except Exception as e:
|
|||
|
|
log.error(f"P2P sync error: {e}")
|
|||
|
|
return jsonify({"error": "SYNC_ERROR", "message": str(e)}), 500
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.route('/api/node/peers')
|
|||
|
|
def api_node_peers():
|
|||
|
|
"""List all known peers — seed nodes + connected Mac apps"""
|
|||
|
|
peers = []
|
|||
|
|
for node in PEER_NODES:
|
|||
|
|
peers.append({
|
|||
|
|
"name": node["name"],
|
|||
|
|
"url": node["url"],
|
|||
|
|
"ip": node["ip"],
|
|||
|
|
"type": "full_node"
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
# Connected Mac app clients
|
|||
|
|
with _mac_peers_lock:
|
|||
|
|
for addr, info in list(_mac_peers.items()):
|
|||
|
|
peers.append({
|
|||
|
|
"name": f"mac-{addr[:8]}",
|
|||
|
|
"type": "client",
|
|||
|
|
"address": addr,
|
|||
|
|
"last_seen": info.get("last_seen")
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
return jsonify({
|
|||
|
|
"peers": peers,
|
|||
|
|
"total": len(peers),
|
|||
|
|
"node_id": NODE_ID
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.route('/api/node/register', methods=['POST'])
|
|||
|
|
@rate_limit(limit=10, window=60)
|
|||
|
|
def api_node_register():
|
|||
|
|
"""
|
|||
|
|
Register a Mac app as a peer client.
|
|||
|
|
|
|||
|
|
POST /api/node/register
|
|||
|
|
Body: {"address": "mt...", "public_key": "..."}
|
|||
|
|
"""
|
|||
|
|
data = request.get_json()
|
|||
|
|
if not data:
|
|||
|
|
return jsonify({"error": "NO_DATA"}), 400
|
|||
|
|
|
|||
|
|
address = data.get('address', '')
|
|||
|
|
if not _validate_address(address):
|
|||
|
|
return jsonify({"error": "INVALID_ADDRESS"}), 400
|
|||
|
|
|
|||
|
|
# [FIX R9 CWE-400] Cap peer registrations + evict stale entries
|
|||
|
|
MAX_MAC_PEERS = 10_000
|
|||
|
|
PEER_STALE_HOURS = 24
|
|||
|
|
with _mac_peers_lock:
|
|||
|
|
# Evict stale peers if at capacity
|
|||
|
|
if len(_mac_peers) >= MAX_MAC_PEERS:
|
|||
|
|
now = datetime.utcnow()
|
|||
|
|
stale_keys = [k for k, v in _mac_peers.items()
|
|||
|
|
if (now - datetime.fromisoformat(v.get('last_seen', '2000-01-01'))).total_seconds() > PEER_STALE_HOURS * 3600]
|
|||
|
|
for k in stale_keys:
|
|||
|
|
del _mac_peers[k]
|
|||
|
|
if len(_mac_peers) >= MAX_MAC_PEERS:
|
|||
|
|
return jsonify({"error": "PEER_LIMIT_REACHED", "message": "Too many registered peers"}), 429
|
|||
|
|
_mac_peers[address] = {
|
|||
|
|
"registered_at": datetime.utcnow().isoformat(),
|
|||
|
|
"last_seen": datetime.utcnow().isoformat(),
|
|||
|
|
"ip": _get_client_ip(),
|
|||
|
|
"public_key": data.get('public_key', '')[:200]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
log.info(f"NODE REGISTERED: {address[:16]}... from {_get_client_ip()}")
|
|||
|
|
|
|||
|
|
return jsonify({
|
|||
|
|
"status": "registered",
|
|||
|
|
"address": address,
|
|||
|
|
"node_type": "client",
|
|||
|
|
"seed_nodes": [{"name": n["name"], "url": n["url"]} for n in PEER_NODES]
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# WALLET & REGISTRY SYNC (Full Balance Consensus)
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
@app.route('/api/node/wallet-sync', methods=['POST'])
|
|||
|
|
@rate_limit(limit=30, window=60)
|
|||
|
|
def api_node_wallet_sync():
|
|||
|
|
"""
|
|||
|
|
Full wallet state sync between nodes.
|
|||
|
|
Ensures all nodes have identical balances (like Bitcoin UTXO consensus).
|
|||
|
|
|
|||
|
|
POST /api/node/wallet-sync
|
|||
|
|
Body: {"node_id": "...", "wallets_hash": "sha256 of wallets.json"}
|
|||
|
|
Returns: {
|
|||
|
|
"wallets": {full wallets dict},
|
|||
|
|
"wallets_hash": "our hash",
|
|||
|
|
"ledger_events": N,
|
|||
|
|
"node_id": "..."
|
|||
|
|
}
|
|||
|
|
"""
|
|||
|
|
# [FIX R6 CWE-200] Only allow trusted peers to access full wallet data
|
|||
|
|
client_ip = _get_client_ip()
|
|||
|
|
trusted_ips = {'127.0.0.1', '::1'} | {p['ip'] for p in PEER_NODES}
|
|||
|
|
if client_ip not in trusted_ips:
|
|||
|
|
return jsonify({"error": "UNTRUSTED_SOURCE"}), 403
|
|||
|
|
|
|||
|
|
data = request.get_json() or {}
|
|||
|
|
remote_hash = data.get('wallets_hash', '')
|
|||
|
|
|
|||
|
|
wallets = load_wallets()
|
|||
|
|
local_hash = hashlib.sha256(json.dumps(wallets, sort_keys=True).encode()).hexdigest()
|
|||
|
|
|
|||
|
|
ledger_count = 0
|
|||
|
|
if EVENT_LEDGER_AVAILABLE:
|
|||
|
|
try:
|
|||
|
|
ledger_count = get_event_ledger()._event_counter
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
return jsonify({
|
|||
|
|
"wallets": wallets,
|
|||
|
|
"wallets_hash": local_hash,
|
|||
|
|
"hashes_match": local_hash == remote_hash,
|
|||
|
|
"ledger_events": ledger_count,
|
|||
|
|
"node_id": NODE_ID,
|
|||
|
|
"timestamp": datetime.utcnow().isoformat() + "Z"
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.route('/api/node/registry-sync', methods=['POST'])
|
|||
|
|
@rate_limit(limit=30, window=60)
|
|||
|
|
def api_node_registry_sync():
|
|||
|
|
"""
|
|||
|
|
Full registry sync between nodes.
|
|||
|
|
Ensures all nodes know about all wallet numbers and aliases.
|
|||
|
|
|
|||
|
|
POST /api/node/registry-sync
|
|||
|
|
Body: {"node_id": "..."}
|
|||
|
|
Returns: {"registry": {full registry dict}, "node_id": "..."}
|
|||
|
|
"""
|
|||
|
|
# [FIX R6 CWE-200] Only allow trusted peers to access full registry
|
|||
|
|
client_ip = _get_client_ip()
|
|||
|
|
trusted_ips = {'127.0.0.1', '::1'} | {p['ip'] for p in PEER_NODES}
|
|||
|
|
if client_ip not in trusted_ips:
|
|||
|
|
return jsonify({"error": "UNTRUSTED_SOURCE"}), 403
|
|||
|
|
|
|||
|
|
registry = load_registry()
|
|||
|
|
return jsonify({
|
|||
|
|
"registry": registry,
|
|||
|
|
"node_id": NODE_ID,
|
|||
|
|
"timestamp": datetime.utcnow().isoformat() + "Z"
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# LEDGER VERIFY
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
@app.route('/api/ledger/verify/<address>')
|
|||
|
|
@rate_limit(limit=60, window=60)
|
|||
|
|
def api_ledger_verify(address: str):
|
|||
|
|
"""
|
|||
|
|
Verify balance against EventLedger.
|
|||
|
|
|
|||
|
|
GET /api/ledger/verify/<address>
|
|||
|
|
Returns: {"ledger_balance": N, "cached_balance": N, "verified": bool}
|
|||
|
|
"""
|
|||
|
|
if not _validate_address(address):
|
|||
|
|
return jsonify({"error": "INVALID_ADDRESS"}), 400
|
|||
|
|
|
|||
|
|
cached_balance = int(get_balance(address))
|
|||
|
|
|
|||
|
|
ledger_balance = 0
|
|||
|
|
if EVENT_LEDGER_AVAILABLE:
|
|||
|
|
try:
|
|||
|
|
ledger = get_event_ledger()
|
|||
|
|
ledger_balance = ledger.balance(address)
|
|||
|
|
except Exception as e:
|
|||
|
|
log.warning(f"Ledger verify error: {e}")
|
|||
|
|
|
|||
|
|
return jsonify({
|
|||
|
|
"address": address,
|
|||
|
|
"ledger_balance": ledger_balance,
|
|||
|
|
"cached_balance": cached_balance,
|
|||
|
|
"verified": ledger_balance == cached_balance or ledger_balance == 0,
|
|||
|
|
"timestamp": datetime.utcnow().isoformat() + "Z"
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.route('/api/ledger/stats')
|
|||
|
|
def api_ledger_stats():
|
|||
|
|
"""EventLedger statistics"""
|
|||
|
|
if not EVENT_LEDGER_AVAILABLE:
|
|||
|
|
return jsonify({"error": "EVENT_LEDGER_UNAVAILABLE"}), 503
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
ledger = get_event_ledger()
|
|||
|
|
return jsonify(ledger.stats())
|
|||
|
|
except Exception as e:
|
|||
|
|
return jsonify({"error": "LEDGER_ERROR", "message": str(e)}), 500
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# P2P BACKGROUND SYNC
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
def _apply_ledger_events_to_wallets(events_data):
|
|||
|
|
"""Apply merged EventLedger events to wallet JSON cache"""
|
|||
|
|
try:
|
|||
|
|
# [FIX R6 CWE-362] Use _wallet_lock for atomic wallet updates
|
|||
|
|
with _wallet_lock:
|
|||
|
|
wallets = load_wallets()
|
|||
|
|
changed = False
|
|||
|
|
|
|||
|
|
for evt in events_data:
|
|||
|
|
evt_type = evt.get("event_type", "")
|
|||
|
|
to_addr = evt.get("to_addr", "")
|
|||
|
|
from_addr = evt.get("from_addr", "")
|
|||
|
|
amount = evt.get("amount", 0)
|
|||
|
|
|
|||
|
|
# [FIX R6] Validate addresses before applying
|
|||
|
|
if to_addr and not _validate_address(to_addr):
|
|||
|
|
continue
|
|||
|
|
if from_addr and not _validate_address(from_addr):
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
if evt_type == "EMISSION" and to_addr and amount > 0:
|
|||
|
|
if to_addr not in wallets:
|
|||
|
|
wallets[to_addr] = {"balance": 0, "type": "p2p_sync"}
|
|||
|
|
wallets[to_addr]["balance"] = wallets[to_addr].get("balance", 0) + amount
|
|||
|
|
changed = True
|
|||
|
|
|
|||
|
|
elif evt_type == "TRANSFER" and from_addr and to_addr and amount > 0:
|
|||
|
|
if from_addr in wallets:
|
|||
|
|
wallets[from_addr]["balance"] = max(0, wallets[from_addr].get("balance", 0) - amount)
|
|||
|
|
if to_addr not in wallets:
|
|||
|
|
wallets[to_addr] = {"balance": 0, "type": "p2p_sync"}
|
|||
|
|
wallets[to_addr]["balance"] = wallets[to_addr].get("balance", 0) + amount
|
|||
|
|
changed = True
|
|||
|
|
|
|||
|
|
if changed:
|
|||
|
|
save_wallets(wallets)
|
|||
|
|
except Exception as e:
|
|||
|
|
log.warning(f"Apply ledger events error: {e}")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _detect_node_identity():
|
|||
|
|
"""Detect which node we are based on local IP"""
|
|||
|
|
global NODE_ID
|
|||
|
|
import socket
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
hostname = socket.gethostname()
|
|||
|
|
local_ips = set()
|
|||
|
|
|
|||
|
|
# Get all local IPs
|
|||
|
|
for info in socket.getaddrinfo(hostname, None):
|
|||
|
|
local_ips.add(info[4][0])
|
|||
|
|
|
|||
|
|
# Also try getting the outbound IP
|
|||
|
|
try:
|
|||
|
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|||
|
|
s.connect(("8.8.8.8", 80))
|
|||
|
|
local_ips.add(s.getsockname()[0])
|
|||
|
|
s.close()
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
# Match against known nodes
|
|||
|
|
for node in PEER_NODES:
|
|||
|
|
if node["ip"] in local_ips:
|
|||
|
|
NODE_ID = node["name"]
|
|||
|
|
return NODE_ID
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
log.warning(f"Node identity detection error: {e}")
|
|||
|
|
|
|||
|
|
NODE_ID = os.environ.get("MONTANA_NODE_ID", hostname if 'hostname' in dir() else "unknown")
|
|||
|
|
return NODE_ID
|
|||
|
|
|
|||
|
|
|
|||
|
|
_push_executor = None
|
|||
|
|
|
|||
|
|
def _get_push_executor():
|
|||
|
|
"""ThreadPoolExecutor с лимитом 3 потоков для push."""
|
|||
|
|
global _push_executor
|
|||
|
|
if _push_executor is None:
|
|||
|
|
from concurrent.futures import ThreadPoolExecutor
|
|||
|
|
_push_executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix="event-push")
|
|||
|
|
return _push_executor
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _push_event_to_peers(event_dict: dict):
|
|||
|
|
"""
|
|||
|
|
INSTANT PUSH: отправляет новое событие на все пиры немедленно.
|
|||
|
|
Только события созданные ЭТИМ узлом (без relay).
|
|||
|
|
ThreadPoolExecutor с лимитом 3 потоков.
|
|||
|
|
"""
|
|||
|
|
# Без relay — только свои события
|
|||
|
|
if event_dict.get("node_id") != NODE_ID:
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
import urllib.request
|
|||
|
|
|
|||
|
|
def _push_to_peer(peer):
|
|||
|
|
try:
|
|||
|
|
push_data = json.dumps({
|
|||
|
|
"event": event_dict,
|
|||
|
|
"source_node": NODE_ID,
|
|||
|
|
"timestamp_ns": time_module.time_ns()
|
|||
|
|
}).encode('utf-8')
|
|||
|
|
|
|||
|
|
req = urllib.request.Request(
|
|||
|
|
f"{peer['url']}/api/node/push-event",
|
|||
|
|
data=push_data,
|
|||
|
|
headers={"Content-Type": "application/json"},
|
|||
|
|
method="POST"
|
|||
|
|
)
|
|||
|
|
with urllib.request.urlopen(req, timeout=5) as resp:
|
|||
|
|
result = json.loads(resp.read())
|
|||
|
|
if result.get("accepted"):
|
|||
|
|
log.info(f"PUSH: event → {peer['name']} (instant)")
|
|||
|
|
except Exception as e:
|
|||
|
|
log.debug(f"PUSH to {peer['name']}: {e}")
|
|||
|
|
|
|||
|
|
executor = _get_push_executor()
|
|||
|
|
for peer in PEER_NODES:
|
|||
|
|
if peer["name"] == NODE_ID:
|
|||
|
|
continue
|
|||
|
|
executor.submit(_push_to_peer, peer)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.route('/api/node/push-event', methods=['POST'])
|
|||
|
|
def api_node_push_event():
|
|||
|
|
"""
|
|||
|
|
Приём мгновенного push от пира.
|
|||
|
|
Моментальная финализация — T1 окно = 0.
|
|||
|
|
Только от known peers.
|
|||
|
|
"""
|
|||
|
|
if not EVENT_LEDGER_AVAILABLE:
|
|||
|
|
return jsonify({"accepted": False, "reason": "no_ledger"}), 503
|
|||
|
|
|
|||
|
|
# [FIX R6 CWE-302] Validate source by IP, not just claimed name
|
|||
|
|
client_ip = _get_client_ip()
|
|||
|
|
trusted_ips = {'127.0.0.1', '::1'} | {p['ip'] for p in PEER_NODES}
|
|||
|
|
if client_ip not in trusted_ips:
|
|||
|
|
log.warning(f"Push event rejected from untrusted IP: {client_ip}")
|
|||
|
|
return jsonify({"accepted": False, "reason": "untrusted_source"}), 403
|
|||
|
|
|
|||
|
|
data = request.get_json(silent=True) or {}
|
|||
|
|
event_data = data.get("event")
|
|||
|
|
source_node = data.get("source_node", "unknown")
|
|||
|
|
|
|||
|
|
# Also validate source_node name matches a known peer
|
|||
|
|
known_names = {p["name"] for p in PEER_NODES}
|
|||
|
|
if source_node not in known_names:
|
|||
|
|
return jsonify({"accepted": False, "reason": "unknown_peer"}), 403
|
|||
|
|
|
|||
|
|
if not event_data or not isinstance(event_data, dict):
|
|||
|
|
return jsonify({"accepted": False, "reason": "invalid_event"}), 400
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
ledger = get_event_ledger()
|
|||
|
|
merged = ledger.merge_events([event_data])
|
|||
|
|
|
|||
|
|
if merged > 0:
|
|||
|
|
_apply_ledger_events_to_wallets([event_data])
|
|||
|
|
log.info(f"PUSH RECEIVED: {event_data.get('event_type')} from {source_node} (instant)")
|
|||
|
|
return jsonify({
|
|||
|
|
"accepted": True,
|
|||
|
|
"merged": merged,
|
|||
|
|
"node_id": NODE_ID,
|
|||
|
|
"timestamp_ns": time_module.time_ns()
|
|||
|
|
})
|
|||
|
|
else:
|
|||
|
|
return jsonify({
|
|||
|
|
"accepted": True,
|
|||
|
|
"merged": 0,
|
|||
|
|
"reason": "duplicate",
|
|||
|
|
"node_id": NODE_ID
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
log.error(f"Push event error: {e}")
|
|||
|
|
return jsonify({"accepted": False, "reason": str(e)}), 500
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# AUCTION SYSTEM — Ascending Auction Model
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
@app.route('/api/auction/price/<service_type>')
|
|||
|
|
def api_auction_price(service_type):
|
|||
|
|
"""
|
|||
|
|
Получить текущую цену для следующей покупки сервиса.
|
|||
|
|
|
|||
|
|
GET /api/auction/price/domain
|
|||
|
|
Response: {"service_type": "domain", "next_price": 42, "total_sold": 41}
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
from montana_auction import get_auction_registry, ServiceType
|
|||
|
|
|
|||
|
|
if service_type not in ServiceType.all():
|
|||
|
|
return jsonify({
|
|||
|
|
"error": "INVALID_SERVICE_TYPE",
|
|||
|
|
"valid_types": ServiceType.all()
|
|||
|
|
}), 400
|
|||
|
|
|
|||
|
|
auction = get_auction_registry(DATA_DIR)
|
|||
|
|
next_price = auction.get_current_price(service_type)
|
|||
|
|
total_sold = auction.get_total_sold(service_type)
|
|||
|
|
|
|||
|
|
return jsonify({
|
|||
|
|
"service_type": service_type,
|
|||
|
|
"next_price": next_price,
|
|||
|
|
"total_sold": total_sold
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
log.error(f"Auction price error: {e}")
|
|||
|
|
return jsonify({"error": str(e)}), 500
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.route('/api/auction/stats')
|
|||
|
|
def api_auction_stats():
|
|||
|
|
"""
|
|||
|
|
Статистика аукциона по всем сервисам.
|
|||
|
|
|
|||
|
|
GET /api/auction/stats
|
|||
|
|
Response: {
|
|||
|
|
"total_services_sold": 100,
|
|||
|
|
"total_revenue": 5050,
|
|||
|
|
"services": {
|
|||
|
|
"domain": {"total_sold": 50, "next_price": 51, "revenue": 1275},
|
|||
|
|
"vpn": {"total_sold": 30, "next_price": 31, "revenue": 465},
|
|||
|
|
...
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
from montana_auction import get_auction_registry
|
|||
|
|
|
|||
|
|
auction = get_auction_registry(DATA_DIR)
|
|||
|
|
stats = auction.get_stats()
|
|||
|
|
|
|||
|
|
return jsonify(stats)
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
log.error(f"Auction stats error: {e}")
|
|||
|
|
return jsonify({"error": str(e)}), 500
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.route('/api/auction/history/<service_type>')
|
|||
|
|
def api_auction_history(service_type):
|
|||
|
|
"""
|
|||
|
|
История покупок для сервиса.
|
|||
|
|
|
|||
|
|
GET /api/auction/history/domain?limit=50
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
from montana_auction import get_auction_registry, ServiceType
|
|||
|
|
|
|||
|
|
if service_type not in ServiceType.all():
|
|||
|
|
return jsonify({
|
|||
|
|
"error": "INVALID_SERVICE_TYPE",
|
|||
|
|
"valid_types": ServiceType.all()
|
|||
|
|
}), 400
|
|||
|
|
|
|||
|
|
limit = min(int(request.args.get('limit', 100)), 500)
|
|||
|
|
|
|||
|
|
auction = get_auction_registry(DATA_DIR)
|
|||
|
|
history = auction.get_purchase_history(service_type, limit)
|
|||
|
|
|
|||
|
|
return jsonify({
|
|||
|
|
"service_type": service_type,
|
|||
|
|
"purchases": history,
|
|||
|
|
"count": len(history)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
log.error(f"Auction history error: {e}")
|
|||
|
|
return jsonify({"error": str(e)}), 500
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# DOMAIN SERVICE — Montana Name Service (MNS)
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
@app.route('/api/domain/available/<domain>')
|
|||
|
|
def api_domain_available(domain):
|
|||
|
|
"""
|
|||
|
|
Проверить доступность домена.
|
|||
|
|
|
|||
|
|
GET /api/domain/available/alice
|
|||
|
|
Response: {"domain": "alice", "available": true, "price": 42}
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
from montana_auction import get_domain_service, get_auction_registry, ServiceType
|
|||
|
|
|
|||
|
|
# Убрать @efir.org если есть
|
|||
|
|
domain = domain.lower().replace("@efir.org", "")
|
|||
|
|
|
|||
|
|
domains = get_domain_service(DATA_DIR)
|
|||
|
|
auction = get_auction_registry(DATA_DIR)
|
|||
|
|
|
|||
|
|
available = domains.is_available(domain)
|
|||
|
|
price = auction.get_current_price(ServiceType.DOMAIN)
|
|||
|
|
|
|||
|
|
return jsonify({
|
|||
|
|
"domain": domain,
|
|||
|
|
"full_domain": f"{domain}@efir.org",
|
|||
|
|
"available": available,
|
|||
|
|
"price": price if available else None
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
log.error(f"Domain availability check error: {e}")
|
|||
|
|
return jsonify({"error": str(e)}), 500
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.route('/api/domain/lookup/<domain>')
|
|||
|
|
def api_domain_lookup(domain):
|
|||
|
|
"""
|
|||
|
|
Найти владельца домена.
|
|||
|
|
|
|||
|
|
GET /api/domain/lookup/alice
|
|||
|
|
Response: {
|
|||
|
|
"domain": "alice@efir.org",
|
|||
|
|
"owner": "mt1234...5678",
|
|||
|
|
"registered": "2026-02-13T12:00:00Z",
|
|||
|
|
"price_paid": 42
|
|||
|
|
}
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
from montana_auction import get_domain_service
|
|||
|
|
|
|||
|
|
domains = get_domain_service(DATA_DIR)
|
|||
|
|
info = domains.lookup(domain)
|
|||
|
|
|
|||
|
|
if not info:
|
|||
|
|
return jsonify({"error": "DOMAIN_NOT_FOUND"}), 404
|
|||
|
|
|
|||
|
|
return jsonify(info)
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
log.error(f"Domain lookup error: {e}")
|
|||
|
|
return jsonify({"error": str(e)}), 500
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.route('/api/domain/register', methods=['POST'])
|
|||
|
|
@rate_limit(limit=5, window=60)
|
|||
|
|
def api_domain_register():
|
|||
|
|
"""
|
|||
|
|
Зарегистрировать домен через аукцион.
|
|||
|
|
|
|||
|
|
POST /api/domain/register
|
|||
|
|
Body: {
|
|||
|
|
"domain": "alice",
|
|||
|
|
"owner_address": "mt1234...5678",
|
|||
|
|
"amount": 42
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Response: {
|
|||
|
|
"success": true,
|
|||
|
|
"domain": "alice@efir.org",
|
|||
|
|
"owner": "mt1234...5678",
|
|||
|
|
"price_paid": 42,
|
|||
|
|
"purchase_number": 42
|
|||
|
|
}
|
|||
|
|
"""
|
|||
|
|
data = request.get_json()
|
|||
|
|
if not data:
|
|||
|
|
return jsonify({"error": "NO_DATA"}), 400
|
|||
|
|
|
|||
|
|
domain = data.get('domain', '').lower()
|
|||
|
|
owner_address = data.get('owner_address', '')
|
|||
|
|
amount = data.get('amount', 0)
|
|||
|
|
|
|||
|
|
# Валидация
|
|||
|
|
if not domain:
|
|||
|
|
return jsonify({"error": "DOMAIN_REQUIRED"}), 400
|
|||
|
|
|
|||
|
|
if not _validate_address(owner_address):
|
|||
|
|
return jsonify({"error": "INVALID_ADDRESS"}), 400
|
|||
|
|
|
|||
|
|
if not isinstance(amount, int) or amount <= 0:
|
|||
|
|
return jsonify({"error": "INVALID_AMOUNT"}), 400
|
|||
|
|
|
|||
|
|
# [FIX R6 CWE-862] Require proof of ownership — signature or localhost
|
|||
|
|
# [FIX R8 CWE-294] Include timestamp to prevent replay attacks
|
|||
|
|
timestamp_hdr = request.headers.get('X-Timestamp', '')
|
|||
|
|
pq_message = f"DOMAIN_REGISTER:{owner_address}:{domain}:{amount}:{timestamp_hdr}"
|
|||
|
|
address, verified = _verify_pq_signature(request, message=pq_message)
|
|||
|
|
if not verified:
|
|||
|
|
client_ip = _get_client_ip()
|
|||
|
|
if client_ip not in ('127.0.0.1', '::1'):
|
|||
|
|
return jsonify({"error": "SIGNATURE_REQUIRED",
|
|||
|
|
"message": "Domain registration requires ML-DSA-65 signature"}), 403
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
from montana_auction import get_domain_service
|
|||
|
|
|
|||
|
|
# [FIX R6 CWE-362] Atomic balance check + debit under lock
|
|||
|
|
with _wallet_lock:
|
|||
|
|
wallets = load_wallets()
|
|||
|
|
balance = wallets.get(owner_address, {}).get('balance', 0)
|
|||
|
|
|
|||
|
|
if balance < amount:
|
|||
|
|
return jsonify({
|
|||
|
|
"error": "INSUFFICIENT_BALANCE",
|
|||
|
|
"balance": balance,
|
|||
|
|
"required": amount
|
|||
|
|
}), 400
|
|||
|
|
|
|||
|
|
# Зарегистрировать домен через аукцион
|
|||
|
|
domains = get_domain_service(DATA_DIR)
|
|||
|
|
result = domains.register(
|
|||
|
|
domain=domain,
|
|||
|
|
owner_address=owner_address,
|
|||
|
|
price_paid=amount
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Списать Ɉ с баланса (inside lock)
|
|||
|
|
wallets[owner_address]['balance'] -= amount
|
|||
|
|
save_wallets(wallets)
|
|||
|
|
|
|||
|
|
log.info(
|
|||
|
|
f"Domain registered: {domain}@efir.org → {owner_address[:10]}... "
|
|||
|
|
f"for {amount} Ɉ"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Создать событие в EventLedger если доступен
|
|||
|
|
if EVENT_LEDGER_AVAILABLE:
|
|||
|
|
try:
|
|||
|
|
ledger = get_event_ledger()
|
|||
|
|
ledger.add_event(
|
|||
|
|
event_type=EventType.DOMAIN_REGISTER,
|
|||
|
|
from_address=owner_address,
|
|||
|
|
to_address="efir.org",
|
|||
|
|
amount=amount,
|
|||
|
|
metadata={
|
|||
|
|
"domain": f"{domain}@efir.org",
|
|||
|
|
"purchase_number": result["purchase_number"]
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
except Exception as ledger_err:
|
|||
|
|
log.error(f"EventLedger error: {ledger_err}")
|
|||
|
|
|
|||
|
|
return jsonify(result)
|
|||
|
|
|
|||
|
|
except ValueError as ve:
|
|||
|
|
return jsonify({"error": str(ve)}), 400
|
|||
|
|
except Exception as e:
|
|||
|
|
log.error(f"Domain register error: {e}")
|
|||
|
|
return jsonify({"error": str(e)}), 500
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# PHONE SERVICE — Virtual Phone Numbers & Calls
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
@app.route('/api/phone/available/<int:number>')
|
|||
|
|
def api_phone_available(number):
|
|||
|
|
"""
|
|||
|
|
Проверить доступность виртуального номера.
|
|||
|
|
|
|||
|
|
GET /api/phone/available/42
|
|||
|
|
Response: {"number": 42, "formatted": "+montana-000042", "available": true, "price": 123}
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
from montana_auction import get_phone_service, get_auction_registry, ServiceType
|
|||
|
|
|
|||
|
|
phones = get_phone_service(DATA_DIR)
|
|||
|
|
auction = get_auction_registry(DATA_DIR)
|
|||
|
|
|
|||
|
|
available = phones.is_available(number)
|
|||
|
|
price = auction.get_current_price(ServiceType.PHONE_NUMBER)
|
|||
|
|
formatted = phones.format_number(number)
|
|||
|
|
|
|||
|
|
return jsonify({
|
|||
|
|
"number": number,
|
|||
|
|
"formatted": formatted,
|
|||
|
|
"available": available,
|
|||
|
|
"price": price if available else None
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
log.error(f"Phone availability check error: {e}")
|
|||
|
|
return jsonify({"error": str(e)}), 500
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.route('/api/phone/lookup/<int:number>')
|
|||
|
|
def api_phone_lookup(number):
|
|||
|
|
"""
|
|||
|
|
Найти владельца виртуального номера.
|
|||
|
|
|
|||
|
|
GET /api/phone/lookup/42
|
|||
|
|
Response: {
|
|||
|
|
"phone_number": "+montana-000042",
|
|||
|
|
"owner": "mt1234...5678",
|
|||
|
|
"registered": "2026-02-13T12:00:00Z",
|
|||
|
|
"price_paid": 42
|
|||
|
|
}
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
from montana_auction import get_phone_service
|
|||
|
|
|
|||
|
|
phones = get_phone_service(DATA_DIR)
|
|||
|
|
info = phones.lookup(number)
|
|||
|
|
|
|||
|
|
if not info:
|
|||
|
|
return jsonify({"error": "PHONE_NUMBER_NOT_FOUND"}), 404
|
|||
|
|
|
|||
|
|
return jsonify(info)
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
log.error(f"Phone lookup error: {e}")
|
|||
|
|
return jsonify({"error": str(e)}), 500
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.route('/api/phone/register', methods=['POST'])
|
|||
|
|
@rate_limit(limit=5, window=60)
|
|||
|
|
def api_phone_register():
|
|||
|
|
"""
|
|||
|
|
Зарегистрировать виртуальный номер через аукцион.
|
|||
|
|
|
|||
|
|
POST /api/phone/register
|
|||
|
|
Body: {
|
|||
|
|
"number": 42,
|
|||
|
|
"owner_address": "mt1234...5678",
|
|||
|
|
"amount": 123
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Response: {
|
|||
|
|
"success": true,
|
|||
|
|
"phone_number": "+montana-000042",
|
|||
|
|
"owner": "mt1234...5678",
|
|||
|
|
"price_paid": 123,
|
|||
|
|
"purchase_number": 123
|
|||
|
|
}
|
|||
|
|
"""
|
|||
|
|
data = request.get_json()
|
|||
|
|
if not data:
|
|||
|
|
return jsonify({"error": "NO_DATA"}), 400
|
|||
|
|
|
|||
|
|
number = data.get('number', 0)
|
|||
|
|
owner_address = data.get('owner_address', '')
|
|||
|
|
amount = data.get('amount', 0)
|
|||
|
|
|
|||
|
|
# Валидация
|
|||
|
|
if not isinstance(number, int) or number <= 0:
|
|||
|
|
return jsonify({"error": "INVALID_NUMBER"}), 400
|
|||
|
|
|
|||
|
|
if not _validate_address(owner_address):
|
|||
|
|
return jsonify({"error": "INVALID_ADDRESS"}), 400
|
|||
|
|
|
|||
|
|
if not isinstance(amount, int) or amount <= 0:
|
|||
|
|
return jsonify({"error": "INVALID_AMOUNT"}), 400
|
|||
|
|
|
|||
|
|
# [FIX R6 CWE-862] Require proof of ownership — signature or localhost
|
|||
|
|
# [FIX R8 CWE-294] Include timestamp to prevent replay attacks
|
|||
|
|
timestamp_hdr = request.headers.get('X-Timestamp', '')
|
|||
|
|
pq_message = f"PHONE_REGISTER:{owner_address}:{number}:{amount}:{timestamp_hdr}"
|
|||
|
|
address, verified = _verify_pq_signature(request, message=pq_message)
|
|||
|
|
if not verified:
|
|||
|
|
client_ip = _get_client_ip()
|
|||
|
|
if client_ip not in ('127.0.0.1', '::1'):
|
|||
|
|
return jsonify({"error": "SIGNATURE_REQUIRED",
|
|||
|
|
"message": "Phone registration requires ML-DSA-65 signature"}), 403
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
from montana_auction import get_phone_service
|
|||
|
|
|
|||
|
|
# [FIX R6 CWE-362] Atomic balance check + debit under lock
|
|||
|
|
with _wallet_lock:
|
|||
|
|
wallets = load_wallets()
|
|||
|
|
balance = wallets.get(owner_address, {}).get('balance', 0)
|
|||
|
|
|
|||
|
|
if balance < amount:
|
|||
|
|
return jsonify({
|
|||
|
|
"error": "INSUFFICIENT_BALANCE",
|
|||
|
|
"balance": balance,
|
|||
|
|
"required": amount
|
|||
|
|
}), 400
|
|||
|
|
|
|||
|
|
# Зарегистрировать номер через аукцион
|
|||
|
|
phones = get_phone_service(DATA_DIR)
|
|||
|
|
result = phones.register(
|
|||
|
|
number=number,
|
|||
|
|
owner_address=owner_address,
|
|||
|
|
price_paid=amount
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Списать Ɉ с баланса (inside lock)
|
|||
|
|
wallets[owner_address]['balance'] -= amount
|
|||
|
|
save_wallets(wallets)
|
|||
|
|
|
|||
|
|
log.info(
|
|||
|
|
f"Phone registered: {result['phone_number']} → {owner_address[:10]}... "
|
|||
|
|
f"for {amount} Ɉ"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Создать событие в EventLedger если доступен
|
|||
|
|
if EVENT_LEDGER_AVAILABLE:
|
|||
|
|
try:
|
|||
|
|
ledger = get_event_ledger()
|
|||
|
|
ledger.add_event(
|
|||
|
|
event_type=EventType.PHONE_REGISTER,
|
|||
|
|
from_address=owner_address,
|
|||
|
|
to_address="montana.phone",
|
|||
|
|
amount=amount,
|
|||
|
|
metadata={
|
|||
|
|
"phone_number": result['phone_number'],
|
|||
|
|
"purchase_number": result["purchase_number"]
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
except Exception as ledger_err:
|
|||
|
|
log.error(f"EventLedger error: {ledger_err}")
|
|||
|
|
|
|||
|
|
return jsonify(result)
|
|||
|
|
|
|||
|
|
except ValueError as ve:
|
|||
|
|
return jsonify({"error": str(ve)}), 400
|
|||
|
|
except Exception as e:
|
|||
|
|
log.error(f"Phone register error: {e}")
|
|||
|
|
return jsonify({"error": str(e)}), 500
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.route('/api/call/pricing')
|
|||
|
|
def api_call_pricing():
|
|||
|
|
"""
|
|||
|
|
Получить текущие цены на звонки.
|
|||
|
|
|
|||
|
|
GET /api/call/pricing
|
|||
|
|
Response: {
|
|||
|
|
"audio_per_second": 1 Ɉ (fixed for number owners),
|
|||
|
|
"video_per_second": 1 Ɉ (fixed for number owners),
|
|||
|
|
"requires_phone_number": true
|
|||
|
|
}
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
return jsonify({
|
|||
|
|
"audio_per_second": 1,
|
|||
|
|
"video_per_second": 1,
|
|||
|
|
"requires_phone_number": True,
|
|||
|
|
"note": "Fixed pricing for phone number owners: 1 Ɉ per second"
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
log.error(f"Call pricing error: {e}")
|
|||
|
|
return jsonify({"error": str(e)}), 500
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.route('/api/call/calculate', methods=['POST'])
|
|||
|
|
def api_call_calculate():
|
|||
|
|
"""
|
|||
|
|
Рассчитать стоимость звонка.
|
|||
|
|
|
|||
|
|
POST /api/call/calculate
|
|||
|
|
Body: {
|
|||
|
|
"call_type": "audio" or "video",
|
|||
|
|
"duration_seconds": 60
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Response: {
|
|||
|
|
"call_type": "audio",
|
|||
|
|
"duration_seconds": 60,
|
|||
|
|
"cost": 60,
|
|||
|
|
"price_per_second": 1
|
|||
|
|
}
|
|||
|
|
"""
|
|||
|
|
data = request.get_json()
|
|||
|
|
if not data:
|
|||
|
|
return jsonify({"error": "NO_DATA"}), 400
|
|||
|
|
|
|||
|
|
call_type = data.get('call_type', 'audio')
|
|||
|
|
duration = data.get('duration_seconds', 0)
|
|||
|
|
|
|||
|
|
if call_type not in ['audio', 'video']:
|
|||
|
|
return jsonify({"error": "INVALID_CALL_TYPE"}), 400
|
|||
|
|
|
|||
|
|
if not isinstance(duration, int) or duration <= 0:
|
|||
|
|
return jsonify({"error": "INVALID_DURATION"}), 400
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
# Fixed pricing: 1 Ɉ per second for number owners
|
|||
|
|
price_per_second = 1
|
|||
|
|
cost = duration * price_per_second
|
|||
|
|
|
|||
|
|
return jsonify({
|
|||
|
|
"call_type": call_type,
|
|||
|
|
"duration_seconds": duration,
|
|||
|
|
"cost": cost,
|
|||
|
|
"price_per_second": price_per_second
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
log.error(f"Call calculation error: {e}")
|
|||
|
|
return jsonify({"error": str(e)}), 500
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.route('/api/call/record', methods=['POST'])
|
|||
|
|
@rate_limit(limit=30, window=60)
|
|||
|
|
def api_call_record():
|
|||
|
|
"""
|
|||
|
|
Записать завершенный звонок и списать Ɉ.
|
|||
|
|
|
|||
|
|
POST /api/call/record
|
|||
|
|
Body: {
|
|||
|
|
"caller_address": "mt1234...",
|
|||
|
|
"callee_address": "mt5678...",
|
|||
|
|
"call_type": "audio" or "video",
|
|||
|
|
"duration_seconds": 60
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Response: {
|
|||
|
|
"success": true,
|
|||
|
|
"call_type": "audio",
|
|||
|
|
"duration_seconds": 60,
|
|||
|
|
"cost": 60,
|
|||
|
|
"caller_balance": 1234
|
|||
|
|
}
|
|||
|
|
"""
|
|||
|
|
data = request.get_json()
|
|||
|
|
if not data:
|
|||
|
|
return jsonify({"error": "NO_DATA"}), 400
|
|||
|
|
|
|||
|
|
caller_address = data.get('caller_address', '')
|
|||
|
|
callee_address = data.get('callee_address', '')
|
|||
|
|
call_type = data.get('call_type', 'audio')
|
|||
|
|
duration = data.get('duration_seconds', 0)
|
|||
|
|
|
|||
|
|
# Валидация
|
|||
|
|
if not _validate_address(caller_address):
|
|||
|
|
return jsonify({"error": "INVALID_CALLER_ADDRESS"}), 400
|
|||
|
|
|
|||
|
|
if not _validate_address(callee_address):
|
|||
|
|
return jsonify({"error": "INVALID_CALLEE_ADDRESS"}), 400
|
|||
|
|
|
|||
|
|
if call_type not in ['audio', 'video']:
|
|||
|
|
return jsonify({"error": "INVALID_CALL_TYPE"}), 400
|
|||
|
|
|
|||
|
|
if not isinstance(duration, int) or duration <= 0 or duration > 86400:
|
|||
|
|
return jsonify({"error": "INVALID_DURATION"}), 400
|
|||
|
|
|
|||
|
|
# [FIX R6 CWE-862] Require proof of ownership — signature or localhost
|
|||
|
|
# [FIX R8 CWE-294] Include timestamp to prevent replay attacks
|
|||
|
|
timestamp_hdr = request.headers.get('X-Timestamp', '')
|
|||
|
|
pq_message = f"CALL_RECORD:{caller_address}:{callee_address}:{call_type}:{duration}:{timestamp_hdr}"
|
|||
|
|
address, verified = _verify_pq_signature(request, message=pq_message)
|
|||
|
|
if not verified:
|
|||
|
|
client_ip = _get_client_ip()
|
|||
|
|
if client_ip not in ('127.0.0.1', '::1'):
|
|||
|
|
return jsonify({"error": "SIGNATURE_REQUIRED",
|
|||
|
|
"message": "Call recording requires ML-DSA-65 signature"}), 403
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
from montana_auction import get_phone_service
|
|||
|
|
|
|||
|
|
phones = get_phone_service(DATA_DIR)
|
|||
|
|
|
|||
|
|
# Fixed pricing: 1 Ɉ per second
|
|||
|
|
cost = duration * 1
|
|||
|
|
|
|||
|
|
# [FIX R6 CWE-362] Atomic balance check + debit under lock
|
|||
|
|
with _wallet_lock:
|
|||
|
|
wallets = load_wallets()
|
|||
|
|
|
|||
|
|
if caller_address not in wallets:
|
|||
|
|
return jsonify({"error": "CALLER_NOT_FOUND"}), 404
|
|||
|
|
|
|||
|
|
if wallets[caller_address]['balance'] < cost:
|
|||
|
|
return jsonify({
|
|||
|
|
"error": "INSUFFICIENT_BALANCE",
|
|||
|
|
"balance": wallets[caller_address]['balance'],
|
|||
|
|
"required": cost
|
|||
|
|
}), 400
|
|||
|
|
|
|||
|
|
# Списать Ɉ с баланса звонящего (inside lock)
|
|||
|
|
wallets[caller_address]['balance'] -= cost
|
|||
|
|
save_wallets(wallets)
|
|||
|
|
|
|||
|
|
log.info(
|
|||
|
|
f"Call recorded: {caller_address[:10]}... → {callee_address[:10]}... "
|
|||
|
|
f"{call_type} {duration}s ({cost} Ɉ)"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Создать событие в EventLedger
|
|||
|
|
if EVENT_LEDGER_AVAILABLE:
|
|||
|
|
try:
|
|||
|
|
ledger = get_event_ledger()
|
|||
|
|
ledger.add_event(
|
|||
|
|
event_type=f"call_{call_type}",
|
|||
|
|
from_address=caller_address,
|
|||
|
|
to_address=callee_address,
|
|||
|
|
amount=cost,
|
|||
|
|
metadata={
|
|||
|
|
"call_type": call_type,
|
|||
|
|
"duration_seconds": duration,
|
|||
|
|
"price_per_second": 1
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
except Exception as ledger_err:
|
|||
|
|
log.error(f"EventLedger error: {ledger_err}")
|
|||
|
|
|
|||
|
|
return jsonify({
|
|||
|
|
"success": True,
|
|||
|
|
"call_type": call_type,
|
|||
|
|
"duration_seconds": duration,
|
|||
|
|
"cost": cost,
|
|||
|
|
"caller_balance": wallets[caller_address]['balance']
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
except ValueError as ve:
|
|||
|
|
return jsonify({"error": str(ve)}), 400
|
|||
|
|
except Exception as e:
|
|||
|
|
log.error(f"Call record error: {e}")
|
|||
|
|
return jsonify({"error": str(e)}), 500
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# REAL PHONE BINDING — SMS Verification for Real Numbers
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
@app.route('/api/phone/bind/request', methods=['POST'])
|
|||
|
|
@rate_limit(limit=5, window=60)
|
|||
|
|
def api_phone_bind_request():
|
|||
|
|
"""
|
|||
|
|
Запросить привязку реального телефонного номера.
|
|||
|
|
Отправляет SMS с кодом верификации.
|
|||
|
|
|
|||
|
|
POST /api/phone/bind/request
|
|||
|
|
Body: {
|
|||
|
|
"phone": "+7-921-123-4567",
|
|||
|
|
"montana_address": "mt1234...5678"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Response: {
|
|||
|
|
"status": "code_sent",
|
|||
|
|
"phone": "+79211234567",
|
|||
|
|
"expires": "2026-02-13T12:10:00Z"
|
|||
|
|
}
|
|||
|
|
"""
|
|||
|
|
data = request.get_json()
|
|||
|
|
if not data:
|
|||
|
|
return jsonify({"error": "NO_DATA"}), 400
|
|||
|
|
|
|||
|
|
phone = data.get('phone', '')
|
|||
|
|
montana_address = data.get('montana_address', '')
|
|||
|
|
|
|||
|
|
# Валидация
|
|||
|
|
if not phone:
|
|||
|
|
return jsonify({"error": "PHONE_REQUIRED"}), 400
|
|||
|
|
|
|||
|
|
if not _validate_address(montana_address):
|
|||
|
|
return jsonify({"error": "INVALID_ADDRESS"}), 400
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
from montana_real_phone import get_real_phone_service
|
|||
|
|
|
|||
|
|
real_phones = get_real_phone_service(DATA_DIR)
|
|||
|
|
result = real_phones.request_verification(
|
|||
|
|
phone=phone,
|
|||
|
|
montana_address=montana_address
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
log.info(
|
|||
|
|
f"Phone bind requested: {result['phone']} → {montana_address[:10]}..."
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
return jsonify(result)
|
|||
|
|
|
|||
|
|
except ValueError as ve:
|
|||
|
|
return jsonify({"error": str(ve)}), 400
|
|||
|
|
except Exception as e:
|
|||
|
|
log.error(f"Phone bind request error: {e}")
|
|||
|
|
return jsonify({"error": str(e)}), 500
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.route('/api/phone/bind/verify', methods=['POST'])
|
|||
|
|
@rate_limit(limit=10, window=60)
|
|||
|
|
def api_phone_bind_verify():
|
|||
|
|
"""
|
|||
|
|
Проверить код верификации и завершить привязку номера.
|
|||
|
|
|
|||
|
|
POST /api/phone/bind/verify
|
|||
|
|
Body: {
|
|||
|
|
"phone": "+7-921-123-4567",
|
|||
|
|
"code": "123456"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Response: {
|
|||
|
|
"status": "verified",
|
|||
|
|
"phone": "+79211234567",
|
|||
|
|
"montana_address": "mt1234...5678",
|
|||
|
|
"verified": "2026-02-13T12:05:00Z"
|
|||
|
|
}
|
|||
|
|
"""
|
|||
|
|
data = request.get_json()
|
|||
|
|
if not data:
|
|||
|
|
return jsonify({"error": "NO_DATA"}), 400
|
|||
|
|
|
|||
|
|
phone = data.get('phone', '')
|
|||
|
|
code = data.get('code', '')
|
|||
|
|
|
|||
|
|
# Валидация
|
|||
|
|
if not phone:
|
|||
|
|
return jsonify({"error": "PHONE_REQUIRED"}), 400
|
|||
|
|
|
|||
|
|
if not code or len(code) != 6:
|
|||
|
|
return jsonify({"error": "INVALID_CODE"}), 400
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
from montana_real_phone import get_real_phone_service
|
|||
|
|
|
|||
|
|
real_phones = get_real_phone_service(DATA_DIR)
|
|||
|
|
result = real_phones.verify_code(
|
|||
|
|
phone=phone,
|
|||
|
|
code=code
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
log.info(
|
|||
|
|
f"Phone verified: {result['phone']} → {result['montana_address'][:10]}..."
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Создать событие в EventLedger
|
|||
|
|
if EVENT_LEDGER_AVAILABLE:
|
|||
|
|
try:
|
|||
|
|
ledger = get_event_ledger()
|
|||
|
|
ledger.add_event(
|
|||
|
|
event_type="phone_verified",
|
|||
|
|
from_address=result['montana_address'],
|
|||
|
|
to_address="montana.phone",
|
|||
|
|
amount=0, # Привязка бесплатная
|
|||
|
|
metadata={
|
|||
|
|
"phone": result['phone'],
|
|||
|
|
"verified": result['verified']
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
except Exception as ledger_err:
|
|||
|
|
log.error(f"EventLedger error: {ledger_err}")
|
|||
|
|
|
|||
|
|
return jsonify(result)
|
|||
|
|
|
|||
|
|
except ValueError as ve:
|
|||
|
|
return jsonify({"error": str(ve)}), 400
|
|||
|
|
except Exception as e:
|
|||
|
|
log.error(f"Phone bind verify error: {e}")
|
|||
|
|
return jsonify({"error": str(e)}), 500
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.route('/api/phone/bind/lookup/<phone>')
|
|||
|
|
def api_phone_bind_lookup(phone):
|
|||
|
|
"""
|
|||
|
|
Найти Montana адрес по реальному телефонному номеру.
|
|||
|
|
|
|||
|
|
GET /api/phone/bind/lookup/+79211234567
|
|||
|
|
Response: {
|
|||
|
|
"phone": "+79211234567",
|
|||
|
|
"montana_address": "mt1234...5678",
|
|||
|
|
"verified": "2026-02-13T12:00:00Z"
|
|||
|
|
}
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
from montana_real_phone import get_real_phone_service
|
|||
|
|
|
|||
|
|
real_phones = get_real_phone_service(DATA_DIR)
|
|||
|
|
info = real_phones.lookup(phone)
|
|||
|
|
|
|||
|
|
if not info:
|
|||
|
|
return jsonify({"error": "PHONE_NOT_FOUND"}), 404
|
|||
|
|
|
|||
|
|
return jsonify(info)
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
log.error(f"Phone bind lookup error: {e}")
|
|||
|
|
return jsonify({"error": str(e)}), 500
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.route('/api/phone/bind/address/<address>')
|
|||
|
|
def api_phone_bind_address(address):
|
|||
|
|
"""
|
|||
|
|
Найти все реальные номера привязанные к Montana адресу.
|
|||
|
|
|
|||
|
|
GET /api/phone/bind/address/mt1234...5678
|
|||
|
|
Response: {
|
|||
|
|
"montana_address": "mt1234...5678",
|
|||
|
|
"phones": ["+79211234567", "+15551234567"]
|
|||
|
|
}
|
|||
|
|
"""
|
|||
|
|
if not _validate_address(address):
|
|||
|
|
return jsonify({"error": "INVALID_ADDRESS"}), 400
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
from montana_real_phone import get_real_phone_service
|
|||
|
|
|
|||
|
|
real_phones = get_real_phone_service(DATA_DIR)
|
|||
|
|
phones = real_phones.get_by_address(address)
|
|||
|
|
|
|||
|
|
return jsonify({
|
|||
|
|
"montana_address": address,
|
|||
|
|
"phones": phones,
|
|||
|
|
"count": len(phones)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
log.error(f"Phone bind address lookup error: {e}")
|
|||
|
|
return jsonify({"error": str(e)}), 500
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _p2p_sync_loop():
|
|||
|
|
"""
|
|||
|
|
Background thread: periodically sync events with peer nodes.
|
|||
|
|
Runs every 30 seconds. Pulls new events from all peers.
|
|||
|
|
"""
|
|||
|
|
import urllib.request
|
|||
|
|
|
|||
|
|
# Wait for app to start
|
|||
|
|
time_module.sleep(10)
|
|||
|
|
|
|||
|
|
log.info(f"P2P SYNC: background thread started (node={NODE_ID})")
|
|||
|
|
|
|||
|
|
# Track last known event_id per peer
|
|||
|
|
last_known = {}
|
|||
|
|
|
|||
|
|
while True:
|
|||
|
|
try:
|
|||
|
|
if not EVENT_LEDGER_AVAILABLE:
|
|||
|
|
time_module.sleep(60)
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
ledger = get_event_ledger()
|
|||
|
|
|
|||
|
|
for peer in PEER_NODES:
|
|||
|
|
# Skip self
|
|||
|
|
if peer["name"] == NODE_ID:
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
# Get our events to send
|
|||
|
|
peer_last = last_known.get(peer["name"], "")
|
|||
|
|
our_events = ledger.get_events_since(peer_last)
|
|||
|
|
our_event_list = [e.to_dict() for e in our_events[:500]]
|
|||
|
|
|
|||
|
|
# Bidirectional sync via POST /api/node/sync
|
|||
|
|
sync_data = json.dumps({
|
|||
|
|
"events": our_event_list,
|
|||
|
|
"last_event_id": peer_last,
|
|||
|
|
"node_id": NODE_ID
|
|||
|
|
}).encode('utf-8')
|
|||
|
|
|
|||
|
|
req = urllib.request.Request(
|
|||
|
|
f"{peer['url']}/api/node/sync",
|
|||
|
|
data=sync_data,
|
|||
|
|
headers={
|
|||
|
|
"Content-Type": "application/json",
|
|||
|
|
"Accept": "application/json"
|
|||
|
|
},
|
|||
|
|
method="POST"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
|||
|
|
resp_data = json.loads(resp.read())
|
|||
|
|
|
|||
|
|
# Merge events from peer
|
|||
|
|
remote_events = resp_data.get("events", [])
|
|||
|
|
if remote_events:
|
|||
|
|
merged = ledger.merge_events(remote_events)
|
|||
|
|
if merged > 0:
|
|||
|
|
log.info(f"P2P SYNC: merged {merged} events from {peer['name']}")
|
|||
|
|
_apply_ledger_events_to_wallets(remote_events)
|
|||
|
|
|
|||
|
|
# Update tracking
|
|||
|
|
if remote_events:
|
|||
|
|
last_known[peer["name"]] = remote_events[-1].get("event_id", "")
|
|||
|
|
elif our_event_list:
|
|||
|
|
last_known[peer["name"]] = our_event_list[-1].get("event_id", "")
|
|||
|
|
|
|||
|
|
peer_merged = resp_data.get("merged", 0)
|
|||
|
|
if peer_merged > 0:
|
|||
|
|
log.info(f"P2P SYNC: {peer['name']} accepted {peer_merged} of our events")
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
log.debug(f"P2P sync with {peer['name']}: {e}")
|
|||
|
|
|
|||
|
|
# ── WALLET STATE SYNC ──
|
|||
|
|
try:
|
|||
|
|
wallets = load_wallets()
|
|||
|
|
wallets_hash = hashlib.sha256(
|
|||
|
|
json.dumps(wallets, sort_keys=True).encode()
|
|||
|
|
).hexdigest()
|
|||
|
|
|
|||
|
|
ws_data = json.dumps({
|
|||
|
|
"node_id": NODE_ID,
|
|||
|
|
"wallets_hash": wallets_hash
|
|||
|
|
}).encode('utf-8')
|
|||
|
|
|
|||
|
|
ws_req = urllib.request.Request(
|
|||
|
|
f"{peer['url']}/api/node/wallet-sync",
|
|||
|
|
data=ws_data,
|
|||
|
|
headers={"Content-Type": "application/json"},
|
|||
|
|
method="POST"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
with urllib.request.urlopen(ws_req, timeout=15) as ws_resp:
|
|||
|
|
ws_result = json.loads(ws_resp.read())
|
|||
|
|
remote_wallets = ws_result.get("wallets", {})
|
|||
|
|
remote_hash = ws_result.get("wallets_hash", "")
|
|||
|
|
|
|||
|
|
# [FIX R6 CWE-347] Do NOT trust remote balances — only register
|
|||
|
|
# new addresses with ZERO balance. Actual balances come from
|
|||
|
|
# EventLedger events (cryptographically validated).
|
|||
|
|
if remote_hash != wallets_hash and isinstance(remote_wallets, dict):
|
|||
|
|
merged_count = 0
|
|||
|
|
for addr, info in remote_wallets.items():
|
|||
|
|
# Validate address format (mt + 40 hex = 42 chars)
|
|||
|
|
if not isinstance(addr, str) or len(addr) != 42 or not addr.startswith("mt"):
|
|||
|
|
continue
|
|||
|
|
if not isinstance(info, dict):
|
|||
|
|
continue
|
|||
|
|
# Only add NEW wallets (not seen locally) — with zero balance
|
|||
|
|
# Balance will be populated by EventLedger events
|
|||
|
|
if addr not in wallets:
|
|||
|
|
wallets[addr] = {"balance": 0, "type": info.get("type", "p2p_sync")}
|
|||
|
|
merged_count += 1
|
|||
|
|
if merged_count > 0:
|
|||
|
|
save_wallets(wallets)
|
|||
|
|
log.info(f"WALLET SYNC: registered {merged_count} new addresses from {peer['name']}")
|
|||
|
|
|
|||
|
|
except Exception as ws_e:
|
|||
|
|
log.debug(f"Wallet sync with {peer['name']}: {ws_e}")
|
|||
|
|
|
|||
|
|
# ── REGISTRY SYNC ──
|
|||
|
|
try:
|
|||
|
|
registry = load_registry()
|
|||
|
|
|
|||
|
|
rs_data = json.dumps({
|
|||
|
|
"node_id": NODE_ID
|
|||
|
|
}).encode('utf-8')
|
|||
|
|
|
|||
|
|
rs_req = urllib.request.Request(
|
|||
|
|
f"{peer['url']}/api/node/registry-sync",
|
|||
|
|
data=rs_data,
|
|||
|
|
headers={"Content-Type": "application/json"},
|
|||
|
|
method="POST"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
with urllib.request.urlopen(rs_req, timeout=15) as rs_resp:
|
|||
|
|
rs_result = json.loads(rs_resp.read())
|
|||
|
|
remote_registry = rs_result.get("registry", {})
|
|||
|
|
|
|||
|
|
if remote_registry:
|
|||
|
|
merged_count = 0
|
|||
|
|
for num, info in remote_registry.items():
|
|||
|
|
if num not in registry:
|
|||
|
|
registry[num] = info
|
|||
|
|
merged_count += 1
|
|||
|
|
if merged_count > 0:
|
|||
|
|
save_registry(registry)
|
|||
|
|
log.info(f"REGISTRY SYNC: merged {merged_count} entries from {peer['name']}")
|
|||
|
|
|
|||
|
|
except Exception as rs_e:
|
|||
|
|
log.debug(f"Registry sync with {peer['name']}: {rs_e}")
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
log.error(f"P2P sync loop error: {e}")
|
|||
|
|
|
|||
|
|
# Background consistency check every 10 seconds (push handles instant sync)
|
|||
|
|
time_module.sleep(10)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# MAIN
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
if __name__ == '__main__':
|
|||
|
|
# Create data directory
|
|||
|
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|||
|
|
|
|||
|
|
# Detect node identity
|
|||
|
|
_detect_node_identity()
|
|||
|
|
|
|||
|
|
port = int(os.environ.get('PORT', 8889))
|
|||
|
|
host = os.environ.get('HOST', '0.0.0.0')
|
|||
|
|
|
|||
|
|
print(f"""
|
|||
|
|
╔═══════════════════════════════════════════════════════════════╗
|
|||
|
|
║ MONTANA PROTOCOL API v3.1.0 ║
|
|||
|
|
║ P2P Network • Post-Quantum • Independent ║
|
|||
|
|
╠═══════════════════════════════════════════════════════════════╣
|
|||
|
|
║ Node: {NODE_ID:<53s}║
|
|||
|
|
║ ║
|
|||
|
|
║ Core Endpoints: ║
|
|||
|
|
║ GET /api/health - Health check ║
|
|||
|
|
║ GET /api/network - Network status ║
|
|||
|
|
║ GET /api/status - Full status ║
|
|||
|
|
║ GET /api/balance/<address> - Get balance ║
|
|||
|
|
║ POST /api/transfer - Transfer Ɉ ║
|
|||
|
|
║ POST /api/presence - Presence heartbeat ║
|
|||
|
|
║ POST /api/register - Register wallet ║
|
|||
|
|
║ POST /api/agent/register - AI agent wallet ║
|
|||
|
|
║ GET /api/timechain/stats - TimeChain stats ║
|
|||
|
|
║ GET /api/timebank/stats - Time Bank stats ║
|
|||
|
|
║ GET /api/version/<platform> - App version (auto-update) ║
|
|||
|
|
║ ║
|
|||
|
|
║ Auction System (Ascending Auction Model): ║
|
|||
|
|
║ GET /api/auction/price/<t> - Current price (N+1 Ɉ) ║
|
|||
|
|
║ GET /api/auction/stats - Auction statistics ║
|
|||
|
|
║ GET /api/auction/history/<t> - Purchase history ║
|
|||
|
|
║ ║
|
|||
|
|
║ Montana Name Service (MNS): ║
|
|||
|
|
║ GET /api/domain/available/<d> - Check domain availability ║
|
|||
|
|
║ GET /api/domain/lookup/<d> - Lookup domain owner ║
|
|||
|
|
║ POST /api/domain/register - Register domain (auction) ║
|
|||
|
|
║ ║
|
|||
|
|
║ Montana Phone Service (Virtual Numbers & Calls): ║
|
|||
|
|
║ GET /api/phone/available/<n> - Check number availability ║
|
|||
|
|
║ GET /api/phone/lookup/<n> - Lookup number owner ║
|
|||
|
|
║ POST /api/phone/register - Register number (auction) ║
|
|||
|
|
║ GET /api/call/pricing - Call pricing (1 Ɉ/sec) ║
|
|||
|
|
║ POST /api/call/calculate - Calculate call cost ║
|
|||
|
|
║ POST /api/call/record - Record completed call ║
|
|||
|
|
║ ║
|
|||
|
|
║ Real Phone Binding (SMS Verification): ║
|
|||
|
|
║ POST /api/phone/bind/request - Request SMS verification ║
|
|||
|
|
║ POST /api/phone/bind/verify - Verify code & bind ║
|
|||
|
|
║ GET /api/phone/bind/lookup/<p>- Lookup by real phone ║
|
|||
|
|
║ GET /api/phone/bind/address/<a>- Get phones by address ║
|
|||
|
|
║ ║
|
|||
|
|
║ P2P (instant sync): ║
|
|||
|
|
║ POST /api/node/push-event - Instant event push (T1=0) ║
|
|||
|
|
║ GET /api/node/events - Pull events (sync) ║
|
|||
|
|
║ POST /api/node/sync - Bidirectional sync ║
|
|||
|
|
║ GET /api/node/peers - List peers ║
|
|||
|
|
║ POST /api/node/register - Register Mac app client ║
|
|||
|
|
║ GET /api/ledger/verify/<a> - Verify balance vs ledger ║
|
|||
|
|
║ GET /api/ledger/stats - EventLedger stats ║
|
|||
|
|
╚═══════════════════════════════════════════════════════════════╝
|
|||
|
|
""")
|
|||
|
|
|
|||
|
|
# Start P2P background sync thread
|
|||
|
|
if EVENT_LEDGER_AVAILABLE:
|
|||
|
|
sync_thread = threading.Thread(target=_p2p_sync_loop, daemon=True, name="p2p-sync")
|
|||
|
|
sync_thread.start()
|
|||
|
|
print(f"P2P: Background sync thread started (node={NODE_ID})")
|
|||
|
|
else:
|
|||
|
|
print("P2P: EventLedger not available — sync disabled")
|
|||
|
|
|
|||
|
|
# Start TIME_BANK for T2 finalization (every 10 minutes)
|
|||
|
|
if TIME_BANK_AVAILABLE:
|
|||
|
|
bank = get_time_bank()
|
|||
|
|
print(f"⏱️ TIME_BANK: Started (T2 finalization every 10 minutes)")
|
|||
|
|
else:
|
|||
|
|
print("⏱️ TIME_BANK: Not available")
|
|||
|
|
|
|||
|
|
print(f"Starting Montana API on http://{host}:{port}")
|
|||
|
|
app.run(host=host, port=port, debug=False, threaded=True)
|