feat: /api/v2/analiza/* endpoints - sport analytics backend
This commit is contained in:
@@ -0,0 +1,375 @@
|
||||
#!/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")
|
||||
|
||||
_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))
|
||||
Reference in New Issue
Block a user