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

673 lines
27 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
"""
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}Ɉ)"