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

673 lines
27 KiB
Python
Raw Permalink Normal View History

#!/usr/bin/env python3
"""
transaction.py UTXO Model & Transaction Format
Montana Protocol v3.2
UTXO (Unspent Transaction Output) модель данных Таймчейна.
Балансов нет. Есть непотраченные выходы.
Каждый UTXO запись: «на адрес mt можно потратить N Ɉ,
если предъявишь подпись от ключа этого адреса».
Post-quantum security: ML-DSA-65 (FIPS 204) signatures.
"""
import hashlib
import json
import sqlite3
import time
import threading
from dataclasses import dataclass, field
from typing import List, Optional, Tuple, Dict
from node_crypto import (
sign_message,
verify_signature,
public_key_to_address,
validate_address,
)
# ═══════════════════════════════════════════════════════════════════════════════
# TRANSACTION FORMAT
# ═══════════════════════════════════════════════════════════════════════════════
@dataclass
class TxInput:
"""
Вход транзакции ссылка на непотраченный выход.
При трате UTXO раскрывается публичный ключ владельца
и предоставляется ML-DSA-65 подпись tx_hash.
"""
tx_hash: str # Хеш транзакции, создавшей выход
output_idx: int # Индекс выхода в той транзакции (0, 1, ...)
pubkey: str # Публичный ключ отправителя (1952 bytes hex)
signature: str # ML-DSA-65 подпись tx_hash (3309 bytes hex)
def to_dict(self) -> Dict:
return {
"tx_hash": self.tx_hash,
"output_idx": self.output_idx,
"pubkey": self.pubkey,
"signature": self.signature,
}
def to_dict_unsigned(self) -> Dict:
"""Для вычисления tx_hash (без подписей)"""
return {
"tx_hash": self.tx_hash,
"output_idx": self.output_idx,
}
@classmethod
def from_dict(cls, d: Dict) -> "TxInput":
return cls(
tx_hash=d["tx_hash"],
output_idx=d["output_idx"],
pubkey=d.get("pubkey", ""),
signature=d.get("signature", ""),
)
@dataclass
class TxOutput:
"""
Выход транзакции новый UTXO.
Адрес получателя и сумма в Ɉ (целое число).
"""
address: str # mt... адрес получателя (42 символа)
amount: int # Сумма в Ɉ (целое, без дробей)
def to_dict(self) -> Dict:
return {
"address": self.address,
"amount": self.amount,
}
@classmethod
def from_dict(cls, d: Dict) -> "TxOutput":
return cls(address=d["address"], amount=d["amount"])
@dataclass
class Transaction:
"""
Транзакция Montana Protocol.
Два типа:
- coinbase: нет входов, создаёт новые Ɉ (эмиссия в τ₂)
- transfer: тратит UTXO, создаёт новые выходы
tx_hash вычисляется из canonical serialization
(inputs без подписей + outputs + timestamp + tx_type).
"""
inputs: List[TxInput]
outputs: List[TxOutput]
timestamp: int # Наносекунды UTC
tx_type: str # "coinbase" | "transfer"
tx_hash: str = "" # Вычисляется автоматически
nonce: str = "" # Gemini fix #6: uniqueness for coinbase (t2_index)
def compute_hash(self) -> str:
"""
Каноническая сериализация SHA-256.
Inputs без подписей (чтобы хеш не зависел от подписей).
Gemini fix #6: nonce included for coinbase uniqueness.
"""
data = {
"inputs": [inp.to_dict_unsigned() for inp in self.inputs],
"outputs": [out.to_dict() for out in self.outputs],
"timestamp": self.timestamp,
"tx_type": self.tx_type,
}
if hasattr(self, 'nonce') and self.nonce:
data["nonce"] = self.nonce
canonical = json.dumps(data, sort_keys=True, ensure_ascii=True)
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
def to_dict(self) -> Dict:
d = {
"tx_hash": self.tx_hash,
"inputs": [inp.to_dict() for inp in self.inputs],
"outputs": [out.to_dict() for out in self.outputs],
"timestamp": self.timestamp,
"tx_type": self.tx_type,
}
if self.nonce:
d["nonce"] = self.nonce
return d
@classmethod
def from_dict(cls, d: Dict) -> "Transaction":
tx = cls(
inputs=[TxInput.from_dict(i) for i in d.get("inputs", [])],
outputs=[TxOutput.from_dict(o) for o in d.get("outputs", [])],
timestamp=d["timestamp"],
tx_type=d["tx_type"],
tx_hash=d.get("tx_hash", ""),
nonce=d.get("nonce", ""),
)
if not tx.tx_hash:
tx.tx_hash = tx.compute_hash()
return tx
# ═══════════════════════════════════════════════════════════════════════════════
# TRANSACTION CREATION
# ═══════════════════════════════════════════════════════════════════════════════
# Gemini R3 fix #2: SQLite INTEGER safety (max signed 64-bit)
MAX_SAFE_AMOUNT = 4_611_686_018_427_387_903 # 2^62 - 1
def create_coinbase_tx(address: str, amount: int, t2_index: int) -> Transaction:
"""
Создаёт coinbase-транзакцию (эмиссия).
Нет входов. Один выход на адрес участника.
Количество Ɉ = секунды присутствия × halving_coefficient.
Args:
address: mt... адрес получателя
amount: количество Ɉ
t2_index: номер τ₂ блока (для уникальности хеша)
Returns:
Transaction с tx_type="coinbase"
"""
if amount <= 0:
raise ValueError(f"Coinbase amount must be positive: {amount}")
if amount > MAX_SAFE_AMOUNT:
raise ValueError(f"Coinbase amount exceeds safe limit: {amount} > {MAX_SAFE_AMOUNT}")
if not validate_address(address):
raise ValueError(f"Invalid address: {address}")
tx = Transaction(
inputs=[],
outputs=[TxOutput(address=address, amount=amount)],
timestamp=time.time_ns(),
tx_type="coinbase",
nonce=f"coinbase:{t2_index}:{address}", # Gemini fix #6: unique per t2_index
)
tx.tx_hash = tx.compute_hash()
return tx
def create_transfer_tx(
utxos_to_spend: List[Tuple[str, int, int]], # [(tx_hash, output_idx, amount), ...]
recipient: str,
amount: int,
change_address: str,
private_key: str,
public_key: str,
) -> Transaction:
"""
Создаёт transfer-транзакцию.
1. Собирает inputs из UTXO
2. Создаёт outputs: получатель + сдача
3. Вычисляет tx_hash
4. Подписывает каждый input
Args:
utxos_to_spend: список UTXO для траты [(tx_hash, output_idx, amount)]
recipient: mt... адрес получателя
amount: сколько отправить
change_address: mt... адрес для сдачи (обычно = отправитель)
private_key: hex приватный ключ ML-DSA-65
public_key: hex публичный ключ ML-DSA-65
Returns:
Подписанная Transaction
Raises:
ValueError: если сумма inputs < amount или адреса невалидны
"""
if not validate_address(recipient):
raise ValueError(f"Invalid recipient address: {recipient}")
if not validate_address(change_address):
raise ValueError(f"Invalid change address: {change_address}")
if amount <= 0:
raise ValueError(f"Amount must be positive: {amount}")
if amount > MAX_SAFE_AMOUNT:
raise ValueError(f"Transfer amount exceeds safe limit: {amount} > {MAX_SAFE_AMOUNT}")
total_input = sum(u[2] for u in utxos_to_spend)
if total_input < amount:
raise ValueError(
f"Insufficient funds: have {total_input}, need {amount}"
)
# Создаём inputs (пока без подписей)
inputs = [
TxInput(
tx_hash=u[0],
output_idx=u[1],
pubkey=public_key,
signature="", # Заполним после вычисления tx_hash
)
for u in utxos_to_spend
]
# Создаём outputs
outputs = [TxOutput(address=recipient, amount=amount)]
change = total_input - amount
if change > 0:
outputs.append(TxOutput(address=change_address, amount=change))
# Создаём транзакцию и вычисляем хеш
tx = Transaction(
inputs=inputs,
outputs=outputs,
timestamp=time.time_ns(),
tx_type="transfer",
)
tx.tx_hash = tx.compute_hash()
# Подписываем каждый input
for inp in tx.inputs:
inp.signature = sign_message(private_key, tx.tx_hash)
return tx
# ═══════════════════════════════════════════════════════════════════════════════
# TRANSACTION VALIDATION
# ═══════════════════════════════════════════════════════════════════════════════
def validate_transaction(tx: Transaction, utxo_set: "UTXOSet") -> Tuple[bool, str]:
"""
Полная валидация транзакции.
Для coinbase:
1. inputs пуст
2. tx_type == "coinbase"
3. Все outputs: amount > 0, address валиден
Для transfer:
1. Каждый input ссылается на существующий непотраченный UTXO
2. address(input.pubkey) == UTXO.address
3. ML_DSA_65.verify(pubkey, tx_hash, signature) == True
4. sum(inputs) == sum(outputs) (Montana: без комиссий)
5. Все outputs: amount > 0, address валиден
6. Нет дубликатов inputs (double spend внутри одной tx)
7. tx_hash корректен (пересчёт совпадает)
Returns:
(is_valid, error_message)
"""
# Проверка tx_hash
expected_hash = tx.compute_hash()
if tx.tx_hash != expected_hash:
return False, f"Invalid tx_hash: expected {expected_hash[:16]}..., got {tx.tx_hash[:16]}..."
# Проверка outputs
if not tx.outputs:
return False, "Transaction has no outputs"
for i, out in enumerate(tx.outputs):
if out.amount <= 0:
return False, f"Output #{i}: amount must be positive ({out.amount})"
if out.amount > MAX_SAFE_AMOUNT:
return False, f"Output #{i}: amount exceeds safe limit ({out.amount} > {MAX_SAFE_AMOUNT})"
if not validate_address(out.address):
return False, f"Output #{i}: invalid address {out.address}"
if tx.tx_type == "coinbase":
# Coinbase: нет входов
if tx.inputs:
return False, "Coinbase transaction must have no inputs"
return True, "OK"
elif tx.tx_type == "transfer":
if not tx.inputs:
return False, "Transfer transaction must have inputs"
# Проверка дубликатов inputs
seen_inputs = set()
for inp in tx.inputs:
key = (inp.tx_hash, inp.output_idx)
if key in seen_inputs:
return False, f"Duplicate input: {inp.tx_hash[:16]}...:{inp.output_idx}"
seen_inputs.add(key)
# ML-DSA-65 key sizes (hex)
EXPECTED_PUBKEY_LEN = 3904 # 1952 bytes = 3904 hex chars
EXPECTED_SIG_LEN = 6618 # 3309 bytes = 6618 hex chars
# Проверка каждого input
total_input = 0
for i, inp in enumerate(tx.inputs):
# 0. Pubkey format validation (ML-DSA-65: 1952 bytes = 3904 hex)
if not inp.pubkey or len(inp.pubkey) != EXPECTED_PUBKEY_LEN:
return False, (
f"Input #{i}: invalid pubkey length "
f"(expected {EXPECTED_PUBKEY_LEN}, got {len(inp.pubkey) if inp.pubkey else 0})"
)
if not all(c in '0123456789abcdef' for c in inp.pubkey):
return False, f"Input #{i}: pubkey contains non-hex characters"
# 0b. Signature format validation
if not inp.signature or len(inp.signature) != EXPECTED_SIG_LEN:
return False, (
f"Input #{i}: invalid signature length "
f"(expected {EXPECTED_SIG_LEN}, got {len(inp.signature) if inp.signature else 0})"
)
# 1. UTXO существует и не потрачен
utxo = utxo_set.get_utxo(inp.tx_hash, inp.output_idx)
if utxo is None:
return False, f"Input #{i}: UTXO not found ({inp.tx_hash[:16]}...:{inp.output_idx})"
if utxo["spent_by_tx"] is not None:
return False, f"Input #{i}: UTXO already spent by {utxo['spent_by_tx'][:16]}..."
# 2. address(pubkey) == UTXO.address
derived_address = public_key_to_address(inp.pubkey)
if derived_address != utxo["address"]:
return False, f"Input #{i}: pubkey address mismatch (derived={derived_address}, utxo={utxo['address']})"
# 3. ML-DSA-65 подпись валидна
if not verify_signature(inp.pubkey, tx.tx_hash, inp.signature):
return False, f"Input #{i}: invalid ML-DSA-65 signature"
total_input += utxo["amount"]
# 4. sum(inputs) == sum(outputs)
total_output = sum(out.amount for out in tx.outputs)
if total_input != total_output:
return False, f"Input/output mismatch: inputs={total_input}, outputs={total_output}"
return True, "OK"
else:
return False, f"Unknown tx_type: {tx.tx_type}"
# ═══════════════════════════════════════════════════════════════════════════════
# UTXO SET (SQLite)
# ═══════════════════════════════════════════════════════════════════════════════
class UTXOSet:
"""
Управление непотраченными выходами транзакций.
Каждый UTXO запись в SQLite:
- (tx_hash, output_idx) уникальный идентификатор
- address владелец
- amount сумма в Ɉ
- created_in_tau1 в каком τ₁ блоке создан
- spent_by_tx NULL если не потрачен, иначе tx_hash потратившей TX
Баланс адреса = SUM(amount) WHERE address=? AND spent_by_tx IS NULL
"""
def __init__(self, db_path: str):
self.db_path = db_path
self._lock = threading.Lock()
self._init_db()
def _init_db(self):
"""Создаём таблицу UTXO если не существует"""
with sqlite3.connect(self.db_path) as conn:
# IMMUTABILITY: WAL mode + crash protection
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=FULL")
# IMMUTABILITY: integrity check on startup
result = conn.execute("PRAGMA integrity_check").fetchone()
if result[0] != "ok":
raise RuntimeError(f"UTXO DATABASE CORRUPTION DETECTED: {result[0]}")
conn.execute("""
CREATE TABLE IF NOT EXISTS utxos (
tx_hash TEXT NOT NULL,
output_idx INTEGER NOT NULL,
address TEXT NOT NULL,
amount INTEGER NOT NULL,
created_in_tau1 INTEGER NOT NULL,
spent_by_tx TEXT,
PRIMARY KEY (tx_hash, output_idx)
)
""")
# Индекс для быстрого запроса баланса (непотраченные UTXO адреса)
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_utxo_address_unspent
ON utxos(address) WHERE spent_by_tx IS NULL
""")
# Индекс для проверки total supply
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_utxo_unspent
ON utxos(spent_by_tx) WHERE spent_by_tx IS NULL
""")
conn.commit()
def _conn(self) -> sqlite3.Connection:
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
return conn
def add_outputs(self, tx: Transaction, tau1_block_num: int):
"""
Добавить выходы транзакции в UTXO set.
Вызывается при включении TX в τ₁ блок.
"""
with self._lock:
with self._conn() as conn:
for idx, out in enumerate(tx.outputs):
conn.execute(
"""INSERT OR IGNORE INTO utxos
(tx_hash, output_idx, address, amount, created_in_tau1, spent_by_tx)
VALUES (?, ?, ?, ?, ?, NULL)""",
(tx.tx_hash, idx, out.address, out.amount, tau1_block_num),
)
conn.commit()
def spend_output(self, tx_hash: str, output_idx: int, spending_tx_hash: str) -> bool:
"""
Пометить UTXO как потраченный.
Returns:
True если успешно, False если UTXO не найден или уже потрачен.
"""
with self._lock:
with self._conn() as conn:
cursor = conn.execute(
"""UPDATE utxos SET spent_by_tx = ?
WHERE tx_hash = ? AND output_idx = ? AND spent_by_tx IS NULL""",
(spending_tx_hash, tx_hash, output_idx),
)
conn.commit()
return cursor.rowcount == 1
def get_utxo(self, tx_hash: str, output_idx: int) -> Optional[Dict]:
"""Получить UTXO по идентификатору"""
with self._conn() as conn:
row = conn.execute(
"SELECT * FROM utxos WHERE tx_hash = ? AND output_idx = ?",
(tx_hash, output_idx),
).fetchone()
if row:
return dict(row)
return None
def get_balance(self, address: str) -> int:
"""
Баланс адреса = сумма непотраченных UTXO.
"""
with self._conn() as conn:
row = conn.execute(
"SELECT COALESCE(SUM(amount), 0) as balance FROM utxos WHERE address = ? AND spent_by_tx IS NULL",
(address,),
).fetchone()
return row["balance"]
def get_utxos_for_address(self, address: str) -> List[Dict]:
"""Все непотраченные UTXO адреса"""
with self._conn() as conn:
rows = conn.execute(
"SELECT * FROM utxos WHERE address = ? AND spent_by_tx IS NULL ORDER BY amount DESC",
(address,),
).fetchall()
return [dict(r) for r in rows]
def is_unspent(self, tx_hash: str, output_idx: int) -> bool:
"""Проверить что UTXO не потрачен"""
utxo = self.get_utxo(tx_hash, output_idx)
return utxo is not None and utxo["spent_by_tx"] is None
def total_supply(self) -> int:
"""
Сумма непотраченных UTXO = текущее денежное предложение.
В UTXO модели потраченные выходы уничтожены считаются только живые.
"""
with self._conn() as conn:
row = conn.execute(
"SELECT COALESCE(SUM(amount), 0) as total FROM utxos WHERE spent_by_tx IS NULL"
).fetchone()
return row["total"]
def total_unspent(self) -> int:
"""Alias для total_supply()"""
return self.total_supply()
def total_coinbase_emissions(self) -> int:
"""
Сумма всех coinbase выходов (эмиссия с генезиса).
Coinbase UTXO не имеют входов, поэтому их tx_hash уникальны
и created_in_tau1 >= 0.
Это НЕ зависит от того, потрачены они или нет.
"""
with self._conn() as conn:
row = conn.execute(
"SELECT COALESCE(SUM(amount), 0) as total FROM utxos"
).fetchone()
return row["total"]
def utxo_count(self) -> int:
"""Количество непотраченных UTXO"""
with self._conn() as conn:
row = conn.execute(
"SELECT COUNT(*) as cnt FROM utxos WHERE spent_by_tx IS NULL"
).fetchone()
return row["cnt"]
def all_balances(self) -> Dict[str, int]:
"""Все адреса с ненулевым UTXO-балансом"""
with self._conn() as conn:
rows = conn.execute(
"SELECT address, SUM(amount) as balance FROM utxos "
"WHERE spent_by_tx IS NULL GROUP BY address HAVING balance > 0"
).fetchall()
return {r["address"]: r["balance"] for r in rows}
def select_utxos_for_amount(self, address: str, amount: int) -> List[Dict]:
"""
Выбрать UTXO для покрытия суммы (жадный алгоритм крупные первыми).
Returns:
Список UTXO, сумма которых >= amount.
Пустой список если недостаточно средств.
"""
utxos = self.get_utxos_for_address(address)
selected = []
total = 0
for utxo in utxos:
selected.append(utxo)
total += utxo["amount"]
if total >= amount:
return selected
return [] # Недостаточно средств
def apply_transaction(self, tx: Transaction, tau1_block_num: int) -> Tuple[bool, str]:
"""
Атомарно применить транзакцию к UTXO set.
Для coinbase: добавить outputs.
Для transfer: пометить inputs как потраченные + добавить outputs.
Returns:
(success, error_message)
"""
if tx.tx_type == "coinbase":
self.add_outputs(tx, tau1_block_num)
return True, "OK"
elif tx.tx_type == "transfer":
with self._lock:
with self._conn() as conn:
# Проверяем и тратим inputs атомарно
for inp in tx.inputs:
cursor = conn.execute(
"""UPDATE utxos SET spent_by_tx = ?
WHERE tx_hash = ? AND output_idx = ? AND spent_by_tx IS NULL""",
(tx.tx_hash, inp.tx_hash, inp.output_idx),
)
if cursor.rowcount != 1:
conn.rollback()
return False, f"Failed to spend UTXO {inp.tx_hash[:16]}...:{inp.output_idx}"
# Добавляем outputs
for idx, out in enumerate(tx.outputs):
conn.execute(
"""INSERT INTO utxos
(tx_hash, output_idx, address, amount, created_in_tau1, spent_by_tx)
VALUES (?, ?, ?, ?, ?, NULL)""",
(tx.tx_hash, idx, out.address, out.amount, tau1_block_num),
)
conn.commit()
return True, "OK"
return False, f"Unknown tx_type: {tx.tx_type}"
def verify_supply_invariant(self) -> Tuple[bool, str]:
"""
Критический инвариант: transfer не создаёт и не уничтожает Ɉ.
Проверка: сумма непотраченных UTXO (денежное предложение) > 0
и совпадает с разницей: (все coinbase выходы) - (потраченные coinbase).
Упрощённо: total_supply >= 0 (не отрицательный баланс).
Полная проверка: для каждой transfer TX, sum(inputs) == sum(outputs).
"""
supply = self.total_supply()
if supply < 0:
return False, f"Negative supply: {supply}"
# Проверяем что каждая transfer TX сбалансирована:
# Все потраченные UTXO (spent_by_tx != NULL) группируем по spending TX
# и проверяем что для каждой TX сумма потраченных == сумма созданных
with self._conn() as conn:
# Суммы потраченных по каждой spending TX
spent_rows = conn.execute("""
SELECT spent_by_tx, SUM(amount) as spent_amount
FROM utxos
WHERE spent_by_tx IS NOT NULL
GROUP BY spent_by_tx
""").fetchall()
for row in spent_rows:
spending_tx = row["spent_by_tx"]
spent_amount = row["spent_amount"]
# Сумма выходов этой TX
created = conn.execute("""
SELECT COALESCE(SUM(amount), 0) as created_amount
FROM utxos
WHERE tx_hash = ?
""", (spending_tx,)).fetchone()
created_amount = created["created_amount"]
if spent_amount != created_amount:
return False, (
f"Transfer TX {spending_tx[:16]}... imbalanced: "
f"spent={spent_amount}, created={created_amount}"
)
return True, f"OK (supply={supply}Ɉ)"