331 lines
14 KiB
Python
331 lines
14 KiB
Python
|
|
#!/usr/bin/env python3
|
|||
|
|
"""
|
|||
|
|
timechain_node.py — Montana TimeChain Node Daemon (MAINNET)
|
|||
|
|
|
|||
|
|
Узел таймчейна. Окна времени привязаны к UTC:
|
|||
|
|
- τ₁ (60s): Каждую минуту UTC (XX:XX:00) — presence proof + pending TX
|
|||
|
|
- τ₂ (600s): Каждые 10 минут UTC (XX:X0:00) — финализация, coinbase эмиссия
|
|||
|
|
- τ₃ (14d): Каждые 14 дней — чекпоинт
|
|||
|
|
- τ₄ (4y): Каждые 4 года — халвинг
|
|||
|
|
|
|||
|
|
Все окна привязаны к абсолютному UTC времени, не к моменту старта узла.
|
|||
|
|
ML-DSA-65 подписи на каждом окне.
|
|||
|
|
|
|||
|
|
Usage:
|
|||
|
|
python3 timechain_node.py
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import json
|
|||
|
|
import logging
|
|||
|
|
import signal
|
|||
|
|
import time
|
|||
|
|
import threading
|
|||
|
|
from pathlib import Path
|
|||
|
|
from datetime import datetime, timezone
|
|||
|
|
|
|||
|
|
from timechain import (
|
|||
|
|
TimeChain, TAU1_SECONDS, TAU2_SECONDS, TAU1_PER_TAU2,
|
|||
|
|
GENESIS_TIMESTAMP_NS, TIME_BANK_TOTAL_SECONDS
|
|||
|
|
)
|
|||
|
|
from transaction import create_coinbase_tx
|
|||
|
|
from node_crypto import generate_keypair, public_key_to_address, sign_message
|
|||
|
|
from presence_proof import PresenceChain
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# CONFIGURATION
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
DATA_DIR = Path(__file__).parent / "data"
|
|||
|
|
KEYS_FILE = DATA_DIR / "timechain_keys.json"
|
|||
|
|
DB_PATH = str(DATA_DIR / "timechain.db")
|
|||
|
|
PRESENCE_DB_PATH = str(DATA_DIR / "presence.db")
|
|||
|
|
|
|||
|
|
logging.basicConfig(
|
|||
|
|
level=logging.INFO,
|
|||
|
|
format="%(asctime)s [%(name)s] %(message)s",
|
|||
|
|
datefmt="%H:%M:%S",
|
|||
|
|
)
|
|||
|
|
log = logging.getLogger("timechain_node")
|
|||
|
|
|
|||
|
|
_running = True
|
|||
|
|
|
|||
|
|
|
|||
|
|
def signal_handler(sig, frame):
|
|||
|
|
global _running
|
|||
|
|
log.info("Shutting down...")
|
|||
|
|
_running = False
|
|||
|
|
|
|||
|
|
|
|||
|
|
signal.signal(signal.SIGINT, signal_handler)
|
|||
|
|
signal.signal(signal.SIGTERM, signal_handler)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# UTC TIME WINDOWS
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
def current_tau1_window() -> int:
|
|||
|
|
"""Номер текущего τ₁ окна = UTC minutes since genesis"""
|
|||
|
|
genesis_sec = GENESIS_TIMESTAMP_NS // 1_000_000_000
|
|||
|
|
now = int(time.time())
|
|||
|
|
return (now - genesis_sec) // TAU1_SECONDS
|
|||
|
|
|
|||
|
|
|
|||
|
|
def current_tau2_window() -> int:
|
|||
|
|
"""Номер текущего τ₂ окна = UTC 10-min windows since genesis"""
|
|||
|
|
genesis_sec = GENESIS_TIMESTAMP_NS // 1_000_000_000
|
|||
|
|
now = int(time.time())
|
|||
|
|
return (now - genesis_sec) // TAU2_SECONDS
|
|||
|
|
|
|||
|
|
|
|||
|
|
def next_tau1_boundary() -> float:
|
|||
|
|
"""Timestamp следующей границы τ₁ (ровная минута UTC)"""
|
|||
|
|
now = time.time()
|
|||
|
|
return float((int(now) // TAU1_SECONDS + 1) * TAU1_SECONDS)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def next_tau2_boundary() -> float:
|
|||
|
|
"""Timestamp следующей границы τ₂ (ровные 10 минут UTC)"""
|
|||
|
|
now = time.time()
|
|||
|
|
return float((int(now) // TAU2_SECONDS + 1) * TAU2_SECONDS)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def wait_until(target: float):
|
|||
|
|
"""Sleep до target timestamp, с возможностью graceful shutdown"""
|
|||
|
|
while _running and time.time() < target:
|
|||
|
|
time.sleep(min(0.5, target - time.time()))
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# NODE KEYS
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
def load_or_create_keys():
|
|||
|
|
"""Load node keys from file or generate new ML-DSA-65 keypair"""
|
|||
|
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|||
|
|
|
|||
|
|
if KEYS_FILE.exists():
|
|||
|
|
with open(KEYS_FILE) as f:
|
|||
|
|
keys = json.load(f)
|
|||
|
|
log.info(f"Loaded keys: {keys['node_id']}")
|
|||
|
|
return keys["node_id"], keys["private_key"], keys["public_key"]
|
|||
|
|
|
|||
|
|
log.info("Generating new ML-DSA-65 keypair...")
|
|||
|
|
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.now(timezone.utc).isoformat(),
|
|||
|
|
}
|
|||
|
|
with open(KEYS_FILE, "w") as f:
|
|||
|
|
json.dump(keys, f, indent=2)
|
|||
|
|
|
|||
|
|
import stat
|
|||
|
|
KEYS_FILE.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|||
|
|
log.info(f"Generated node: {node_id}")
|
|||
|
|
return node_id, private_key, public_key
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# PARTICIPANTS (for emission)
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
_active_participants = {}
|
|||
|
|
_participants_lock = threading.Lock()
|
|||
|
|
|
|||
|
|
PRESENCE_QUEUE_FILE = DATA_DIR / "presence_queue.json"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _read_presence_queue() -> dict:
|
|||
|
|
"""Read and clear shared presence queue written by montana_api.py"""
|
|||
|
|
try:
|
|||
|
|
if PRESENCE_QUEUE_FILE.exists():
|
|||
|
|
with open(PRESENCE_QUEUE_FILE, 'r') as f:
|
|||
|
|
data = json.load(f)
|
|||
|
|
# Clear the file after reading
|
|||
|
|
with open(PRESENCE_QUEUE_FILE, 'w') as f:
|
|||
|
|
json.dump({}, f)
|
|||
|
|
return data if isinstance(data, dict) else {}
|
|||
|
|
except Exception as e:
|
|||
|
|
log.warning(f"Presence queue read error: {e}")
|
|||
|
|
return {}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def get_and_reset_emissions():
|
|||
|
|
"""Collect accumulated presence seconds from queue + local, reset counters"""
|
|||
|
|
with _participants_lock:
|
|||
|
|
now = time.time()
|
|||
|
|
emissions = {}
|
|||
|
|
|
|||
|
|
# Read from shared queue (written by API process)
|
|||
|
|
queued = _read_presence_queue()
|
|||
|
|
for addr, seconds in queued.items():
|
|||
|
|
if isinstance(seconds, (int, float)) and seconds > 0:
|
|||
|
|
emissions[addr] = min(int(seconds), TAU2_SECONDS)
|
|||
|
|
|
|||
|
|
# Merge with local in-memory participants
|
|||
|
|
stale = []
|
|||
|
|
for addr, entry in _active_participants.items():
|
|||
|
|
if now - entry["last_seen"] < TAU2_SECONDS + 60:
|
|||
|
|
if entry["total_seconds"] > 0:
|
|||
|
|
existing = emissions.get(addr, 0)
|
|||
|
|
emissions[addr] = min(existing + entry["total_seconds"], TAU2_SECONDS)
|
|||
|
|
else:
|
|||
|
|
stale.append(addr)
|
|||
|
|
|
|||
|
|
for addr in _active_participants:
|
|||
|
|
_active_participants[addr]["total_seconds"] = 0
|
|||
|
|
for addr in stale:
|
|||
|
|
del _active_participants[addr]
|
|||
|
|
|
|||
|
|
return emissions
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# MAIN NODE LOOP
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
def run_node():
|
|||
|
|
global _running
|
|||
|
|
|
|||
|
|
node_id, private_key, public_key = load_or_create_keys()
|
|||
|
|
|
|||
|
|
tc = TimeChain(
|
|||
|
|
node_id=node_id,
|
|||
|
|
private_key=private_key,
|
|||
|
|
public_key=public_key,
|
|||
|
|
db_path=DB_PATH,
|
|||
|
|
)
|
|||
|
|
tc.register_node(node_id, public_key)
|
|||
|
|
|
|||
|
|
presence_chain = PresenceChain(
|
|||
|
|
node_id=node_id,
|
|||
|
|
private_key=private_key,
|
|||
|
|
pubkey=public_key,
|
|||
|
|
db_path=PRESENCE_DB_PATH,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
log.info("=" * 60)
|
|||
|
|
log.info(" Montana TimeChain Node — MAINNET")
|
|||
|
|
log.info(f" Node: {node_id}")
|
|||
|
|
log.info(f" tau1: {tc.tau1_count} windows")
|
|||
|
|
log.info(f" tau2: {tc.tau2_count} windows")
|
|||
|
|
log.info(f" Supply: {tc.utxo_set.total_unspent()} Ɉ")
|
|||
|
|
log.info(f" Proofs: {presence_chain.proof_count}")
|
|||
|
|
log.info("=" * 60)
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════
|
|||
|
|
# IMMUTABILITY: Full chain verification on startup
|
|||
|
|
# ═══════════════════════════════════════════════════════════════
|
|||
|
|
if tc.tau1_count > 0:
|
|||
|
|
log.info("Verifying chain integrity...")
|
|||
|
|
ok, msg = tc.verify_full_chain()
|
|||
|
|
if not ok:
|
|||
|
|
log.error(f"CHAIN VERIFICATION FAILED: {msg}")
|
|||
|
|
raise RuntimeError(f"Corrupted TimeChain — cannot start: {msg}")
|
|||
|
|
log.info(f"Chain verification: {msg}")
|
|||
|
|
|
|||
|
|
# Verify presence proof chain
|
|||
|
|
ok, msg = presence_chain.verify_chain()
|
|||
|
|
if not ok:
|
|||
|
|
log.error(f"PRESENCE CHAIN VERIFICATION FAILED: {msg}")
|
|||
|
|
raise RuntimeError(f"Corrupted presence chain — cannot start: {msg}")
|
|||
|
|
log.info(f"Presence chain: {msg}")
|
|||
|
|
else:
|
|||
|
|
log.info("Empty chain — skipping verification")
|
|||
|
|
|
|||
|
|
tau1_in_current_tau2 = 0
|
|||
|
|
tau2_number = tc.tau2_count
|
|||
|
|
|
|||
|
|
# Wait for next τ₁ boundary (exact UTC minute)
|
|||
|
|
target = next_tau1_boundary()
|
|||
|
|
now_utc = datetime.now(timezone.utc).strftime("%H:%M:%S")
|
|||
|
|
wait_sec = target - time.time()
|
|||
|
|
log.info(f"Now {now_utc} UTC. Waiting {wait_sec:.1f}s for next tau1 window boundary...")
|
|||
|
|
wait_until(target)
|
|||
|
|
|
|||
|
|
while _running:
|
|||
|
|
now = time.time()
|
|||
|
|
now_utc = datetime.now(timezone.utc).strftime("%H:%M:%S")
|
|||
|
|
tau1_window_num = current_tau1_window()
|
|||
|
|
tau2_window_num = current_tau2_window()
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
# ─── τ₁ окно: создание presence proof + запись ────────────
|
|||
|
|
proof = presence_chain.create_proof(t2_index=tau2_window_num)
|
|||
|
|
|
|||
|
|
window = tc.create_tau1_window(
|
|||
|
|
transactions=[],
|
|||
|
|
presence_proof_hashes=[proof.proof_hash],
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
tau1_in_current_tau2 += 1
|
|||
|
|
log.info(
|
|||
|
|
f"τ₁ #{window.window_number} | "
|
|||
|
|
f"{now_utc} UTC | "
|
|||
|
|
f"window={tau1_window_num} | "
|
|||
|
|
f"hash={window.window_hash()[:12]}... | "
|
|||
|
|
f"tau1_in_tau2={tau1_in_current_tau2}/{TAU1_PER_TAU2}"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# ─── τ₂ граница окна: финализация эмиссии ────────────────
|
|||
|
|
utc_minute = int(now) // 60
|
|||
|
|
at_tau2_boundary = (utc_minute % 10 == 9) and (tau1_in_current_tau2 >= TAU1_PER_TAU2)
|
|||
|
|
|
|||
|
|
if at_tau2_boundary:
|
|||
|
|
log.info(f"τ₂ #{tau2_number} — финализация в {now_utc} UTC...")
|
|||
|
|
|
|||
|
|
emissions = get_and_reset_emissions()
|
|||
|
|
|
|||
|
|
if node_id not in emissions:
|
|||
|
|
emissions[node_id] = TAU2_SECONDS
|
|||
|
|
|
|||
|
|
# Халвинг: эра 0 = коэфф 1.0, эра 1 = 0.5, etc.
|
|||
|
|
tau2_per_halving = 2016 * 104
|
|||
|
|
halving_era = tau2_number // tau2_per_halving
|
|||
|
|
halving_coefficient = 1.0 / (2 ** halving_era)
|
|||
|
|
|
|||
|
|
# Конвертируем emissions dict → coinbase транзакции
|
|||
|
|
coinbase_txs = []
|
|||
|
|
for addr, seconds in emissions.items():
|
|||
|
|
amount = int(seconds * halving_coefficient)
|
|||
|
|
if amount > 0:
|
|||
|
|
coinbase_txs.append(
|
|||
|
|
create_coinbase_tx(addr, amount, tau2_number)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
tau2_window = tc.finalize_tau2(
|
|||
|
|
coinbase_txs=coinbase_txs,
|
|||
|
|
halving_coefficient=halving_coefficient,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if tau2_window:
|
|||
|
|
log.info(
|
|||
|
|
f" τ₂ #{tau2_window.window_number} FINALIZED | "
|
|||
|
|
f"emissions={tau2_window.total_emissions} Ɉ | "
|
|||
|
|
f"participants={len(emissions)} | "
|
|||
|
|
f"coef={halving_coefficient}"
|
|||
|
|
)
|
|||
|
|
tau2_number += 1
|
|||
|
|
else:
|
|||
|
|
log.warning(" τ₂ finalization returned None")
|
|||
|
|
|
|||
|
|
tau1_in_current_tau2 = 0
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
log.error(f"Error in main loop: {e}", exc_info=True)
|
|||
|
|
|
|||
|
|
# ─── Wait for next τ₁ boundary ───────────────────────────────
|
|||
|
|
wait_until(next_tau1_boundary())
|
|||
|
|
|
|||
|
|
log.info("Node stopped.")
|
|||
|
|
stats = tc.get_stats()
|
|||
|
|
log.info(f"Final: tau1={stats['tau1_count']}, tau2={stats['tau2_count']}, supply={stats['total_supply']}")
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
run_node()
|