#!/usr/bin/env python3 from __future__ import annotations from dotenv import load_dotenv load_dotenv('/opt/rinet-gpu/.env.master') # auto-added by patch_scrapers_with_dotenv.sh """ 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. """ 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") _pgh = os.environ.get("PG_HOST", "10.10.0.2") _pgp = int(os.environ.get("PG_PORT", "6432")) # pgz-sport.service inherits PG_HOST=localhost:5432 from /opt/.env.rinet which is # stale (local PG was decommissioned). Honour the DB_HOST/DB_PORT override that # points at canonical Server B (10.10.0.2:6432). if _pgh in ("localhost", "127.0.0.1"): _pgh = os.environ.get("DB_HOST", "10.10.0.2") _pgp = int(os.environ.get("DB_PORT", "6432")) DB = dict( host=_pgh, port=_pgp, dbname=os.environ.get("PG_DB", "rinet_v3"), user=os.environ.get("PG_USER", "rinet"), password=os.environ["DB_PASSWORD"], ) # ─── 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))