376 lines
14 KiB
Python
376 lines
14 KiB
Python
#!/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))
|