montana/Русский/Бот/montana_api.py

3397 lines
126 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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)