diff --git a/blockchain/__init__.py b/blockchain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/blockchain/seal.py b/blockchain/seal.py new file mode 100644 index 0000000..d71f030 --- /dev/null +++ b/blockchain/seal.py @@ -0,0 +1,366 @@ +#!/usr/bin/env python3 +""" +seal.py — Polygon PoS sealing module for PGŽ Sport audit log +Author: Damir Radulić (damir@rinet.one) / dradulic@outlook.com +Date: 2026-05-04 +Version: 1.0.0 + +Seals critical audit events to Polygon PoS (chain 137) using the wallet +0xD874345dcB17baBDfbFac9bD7838AdE0D4a5d368. + +Two operating modes: + +1. LIVE — environment provides POLYGON_PRIVKEY (and web3 is installed). + A 0-MATIC self-transaction is sent with the sha256 data hash encoded + in the `data` field. Returns the real 0x… 64-char tx hash. + +2. PENDING — no key configured. The seal record is queued in + pgz_sport.polygon_seals with status='pending' and a deterministic + pseudo-tx-hash (the seal_id, prefixed with 'pending:'). A later + batch job (or operator) can flush the queue once a key is loaded. + +Public surface +-------------- +seal_to_polygon(data_hash, ref_id, action, **kw) -> dict + Returns: { seal_id, tx_hash, status, polygonscan_url, ... } + +verify_seal(seal_id) -> dict + Read-back utility. Cross-checks the on-chain receipt (if web3 is wired up) + and returns the canonical row from polygon_seals. + +list_seals(action=None, ref_type=None, ref_id=None, limit=50) -> list[dict] + Lightweight reader for the audit-seal UI. + +The module is import-safe even on hosts without web3 installed; the LIVE branch +just becomes a no-op. +""" +from __future__ import annotations + +import os +import json +import hashlib +import time +from datetime import datetime, timezone +from typing import Optional, Any + +import psycopg2 +import psycopg2.extras + +# ─── Optional web3 dependency ──────────────────────────────────────────── +try: + from web3 import Web3 + from eth_account import Account + HAS_WEB3 = True +except Exception: + HAS_WEB3 = False + +# ─── Configuration (env-driven) ────────────────────────────────────────── +POLYGON_RPC = os.environ.get("POLYGON_RPC", "https://polygon-rpc.com") +POLYGON_CHAIN_ID = int(os.environ.get("POLYGON_CHAIN_ID", "137")) +POLYGON_WALLET = os.environ.get( + "POLYGON_WALLET", "0xD874345dcB17baBDfbFac9bD7838AdE0D4a5d368" +).strip() +POLYGON_PRIVKEY = os.environ.get("POLYGON_PRIVKEY", "").strip() +POLYGONSCAN_BASE = os.environ.get("POLYGONSCAN_BASE", "https://polygonscan.com") + +DB = dict( + host=os.environ.get("PG_HOST", "10.10.0.2"), + port=int(os.environ.get("PG_PORT", "6432")), + dbname=os.environ.get("PG_DB", "rinet_v3"), + user=os.environ.get("PG_USER", "rinet"), + password=os.environ.get("PG_PASS", "R1net2026!SecureDB#v7"), +) + +# ─── helpers ───────────────────────────────────────────────────────────── + + +def _db(): + c = psycopg2.connect(**DB) + c.autocommit = True + return c + + +def _sha256(*parts: Any) -> str: + h = hashlib.sha256() + for p in parts: + if p is None: + continue + if isinstance(p, (dict, list)): + p = json.dumps(p, sort_keys=True, ensure_ascii=False, default=str) + h.update(str(p).encode("utf-8", errors="replace")) + h.update(b"\x00") + return h.hexdigest() + + +def hash_payload(payload: Any) -> str: + """Public helper — stable sha256 of a payload, JSON-canonicalised.""" + if isinstance(payload, (dict, list)): + payload = json.dumps(payload, sort_keys=True, ensure_ascii=False, default=str) + return hashlib.sha256(str(payload).encode("utf-8", errors="replace")).hexdigest() + + +def polygonscan_url(tx_hash: str) -> Optional[str]: + if not tx_hash or tx_hash.startswith("pending:"): + return None + if not tx_hash.startswith("0x"): + tx_hash = "0x" + tx_hash + return f"{POLYGONSCAN_BASE}/tx/{tx_hash}" + + +# ─── live broadcast path ───────────────────────────────────────────────── + + +def _broadcast_live(data_hash: str, action: str, ref_id: str) -> dict: + """Send a 0-MATIC self-tx encoding `data_hash` in the data field. + + Returns dict with tx_hash, block_number (if mined within wait window), + and status. Raises on RPC errors so the caller can fall back. + """ + if not HAS_WEB3: + raise RuntimeError("web3 not installed") + if not POLYGON_PRIVKEY: + raise RuntimeError("POLYGON_PRIVKEY missing") + + w3 = Web3(Web3.HTTPProvider(POLYGON_RPC, request_kwargs={"timeout": 15})) + acct = Account.from_key(POLYGON_PRIVKEY) + + if acct.address.lower() != POLYGON_WALLET.lower(): + raise RuntimeError( + f"key/address mismatch: key={acct.address} wallet={POLYGON_WALLET}" + ) + + nonce = w3.eth.get_transaction_count(acct.address) + gas_price = w3.eth.gas_price + + # Encode "PGZ|action|ref_id|data_hash" into the data field as utf-8 hex. + memo = f"PGZ|{action}|{ref_id}|0x{data_hash}".encode("utf-8") + tx = { + "to": acct.address, + "value": 0, + "data": "0x" + memo.hex(), + "nonce": nonce, + "chainId": POLYGON_CHAIN_ID, + "gas": 60000, + "gasPrice": gas_price, + } + signed = acct.sign_transaction(tx) + tx_hash = w3.eth.send_raw_transaction(signed.rawTransaction).hex() + + block_number = None + try: + receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=30) + block_number = int(receipt.blockNumber) + status = "confirmed" if receipt.status == 1 else "failed" + except Exception: + status = "broadcast" + + return {"tx_hash": tx_hash, "block_number": block_number, "status": status} + + +# ─── public API ────────────────────────────────────────────────────────── + + +def seal_to_polygon( + data_hash: str, + ref_id: str, + action: str, + *, + ref_type: Optional[str] = None, + payload: Optional[Any] = None, + user_id: Optional[int] = None, + user_email: Optional[str] = None, +) -> dict: + """Seal a sha256 hash to Polygon PoS. + + Always persists a row in pgz_sport.polygon_seals. If LIVE mode succeeds, + the row carries the real tx_hash; otherwise it is left in 'pending' state + so a worker can flush the queue later. + + Parameters + ---------- + data_hash : str + sha256 hex digest of the payload being sealed. + ref_id : str + opaque reference (e.g. "klub:42", "sufinanciranje:2026-001"). + action : str + canonical action name (e.g. "sufinanciranje.approved"). + """ + if not data_hash: + raise ValueError("data_hash required") + data_hash = data_hash.lower().lstrip("0x") + if len(data_hash) != 64 or not all(c in "0123456789abcdef" for c in data_hash): + raise ValueError("data_hash must be 64-char sha256 hex") + + nonce = f"{int(time.time() * 1000):x}" + seal_id = _sha256(action, ref_id, data_hash, nonce) + + row = { + "seal_id": seal_id, + "action": action[:80], + "ref_type": (ref_type or "")[:50] or None, + "ref_id": str(ref_id)[:80] if ref_id is not None else None, + "data_hash": data_hash, + "payload": json.dumps(payload, default=str) if payload is not None else None, + "wallet": POLYGON_WALLET, + "chain_id": POLYGON_CHAIN_ID, + "user_id": user_id, + "user_email": user_email, + } + + tx_hash: Optional[str] = None + block_number: Optional[int] = None + error: Optional[str] = None + status = "pending" + + if HAS_WEB3 and POLYGON_PRIVKEY: + try: + r = _broadcast_live(data_hash, action, str(ref_id)) + tx_hash = r["tx_hash"] + block_number = r.get("block_number") + status = r.get("status", "broadcast") + except Exception as e: + error = f"{type(e).__name__}: {e}"[:500] + status = "pending" + tx_hash = None + else: + # No live key: deterministic "pending" reference. + tx_hash = "pending:" + seal_id[:32] + if not HAS_WEB3: + error = "web3 not installed" + elif not POLYGON_PRIVKEY: + error = "POLYGON_PRIVKEY not set" + + sealed_at = datetime.now(timezone.utc) if status in ("broadcast", "confirmed") else None + + with _db() as c, c.cursor() as cur: + cur.execute( + """ + INSERT INTO pgz_sport.polygon_seals + (seal_id, action, ref_type, ref_id, data_hash, payload, tx_hash, + chain_id, wallet, status, block_number, error, + user_id, user_email, sealed_at) + VALUES (%(seal_id)s, %(action)s, %(ref_type)s, %(ref_id)s, %(data_hash)s, + %(payload)s::jsonb, %(tx_hash)s, %(chain_id)s, %(wallet)s, + %(status)s, %(block_number)s, %(error)s, + %(user_id)s, %(user_email)s, %(sealed_at)s) + ON CONFLICT (seal_id) DO UPDATE + SET tx_hash = EXCLUDED.tx_hash, + status = EXCLUDED.status, + block_number = EXCLUDED.block_number, + error = EXCLUDED.error, + sealed_at = EXCLUDED.sealed_at + RETURNING id, created_at + """, + { + **row, + "tx_hash": tx_hash, + "status": status, + "block_number": block_number, + "error": error, + "sealed_at": sealed_at, + }, + ) + rid, created_at = cur.fetchone() + + return { + "id": rid, + "seal_id": seal_id, + "action": action, + "ref_type": ref_type, + "ref_id": ref_id, + "data_hash": data_hash, + "tx_hash": tx_hash, + "status": status, + "block_number": block_number, + "wallet": POLYGON_WALLET, + "chain_id": POLYGON_CHAIN_ID, + "polygonscan_url": polygonscan_url(tx_hash), + "error": error, + "created_at": created_at.isoformat() if created_at else None, + "live": HAS_WEB3 and bool(POLYGON_PRIVKEY), + } + + +def verify_seal(seal_id: str) -> Optional[dict]: + with _db() as c, c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute( + """SELECT id, seal_id, action, ref_type, ref_id, data_hash, tx_hash, + chain_id, wallet, status, block_number, error, + user_id, user_email, created_at, sealed_at, payload + FROM pgz_sport.polygon_seals WHERE seal_id=%s""", + (seal_id,), + ) + row = cur.fetchone() + if not row: + return None + row = dict(row) + row["polygonscan_url"] = polygonscan_url(row.get("tx_hash")) + if row.get("created_at"): + row["created_at"] = row["created_at"].isoformat() + if row.get("sealed_at"): + row["sealed_at"] = row["sealed_at"].isoformat() + + if HAS_WEB3 and row.get("tx_hash") and not str(row["tx_hash"]).startswith("pending:"): + try: + w3 = Web3(Web3.HTTPProvider(POLYGON_RPC, request_kwargs={"timeout": 8})) + r = w3.eth.get_transaction_receipt(row["tx_hash"]) + row["onchain"] = { + "block_number": int(r.blockNumber), + "status": int(r.status), + "from": r["from"], + "to": r["to"], + } + except Exception as e: + row["onchain"] = {"error": str(e)[:200]} + return row + + +def list_seals( + action: Optional[str] = None, + ref_type: Optional[str] = None, + ref_id: Optional[str] = None, + limit: int = 50, +) -> list[dict]: + where, params = [], [] + if action: + where.append("action = %s") + params.append(action) + if ref_type: + where.append("ref_type = %s") + params.append(ref_type) + if ref_id is not None: + where.append("ref_id = %s") + params.append(str(ref_id)) + sql = ( + "SELECT id, seal_id, action, ref_type, ref_id, data_hash, tx_hash, " + " chain_id, wallet, status, block_number, error, " + " user_id, user_email, created_at, sealed_at " + "FROM pgz_sport.polygon_seals " + + ("WHERE " + " AND ".join(where) + " " if where else "") + + "ORDER BY id DESC LIMIT %s" + ) + params.append(min(int(limit or 50), 500)) + with _db() as c, c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute(sql, params) + rows = [dict(r) for r in cur.fetchall()] + for r in rows: + r["polygonscan_url"] = polygonscan_url(r.get("tx_hash")) + if r.get("created_at"): + r["created_at"] = r["created_at"].isoformat() + if r.get("sealed_at"): + r["sealed_at"] = r["sealed_at"].isoformat() + return rows + + +# ─── self-test ─────────────────────────────────────────────────────────── +if __name__ == "__main__": + payload = {"demo": True, "ts": int(time.time()), "msg": "PGŽ seal self-test"} + h = hash_payload(payload) + res = seal_to_polygon( + h, + ref_id="selftest:1", + action="selftest.run", + ref_type="selftest", + payload=payload, + ) + print(json.dumps(res, indent=2, default=str, ensure_ascii=False))