From 44b9313c6c92b352b4cebdfbc45f74500020461d Mon Sep 17 00:00:00 2001 From: AliaksandrNazaruk Date: Wed, 25 Mar 2026 12:01:35 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20GPU=20Render=20Protocol=20=E2=80=94=20e?= =?UTF-8?q?scrow=20payments=20for=20render/voice/LLM=20jobs=20(Bounty=20#3?= =?UTF-8?q?0,=20100=20RTC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the complete Decentralized GPU Render Protocol: - GPU Node Attestation with hardware fingerprinting (nvidia_gpu, amd_gpu, apple_gpu) - Render/Voice(TTS/STT)/LLM escrow payment endpoints - Pricing oracle with fair market rate tracking and manipulation detection - SQLite-backed storage with WAL mode for concurrent access - Flask route registration for integration with existing RustChain node - 12 unit tests, all passing Endpoints: POST /gpu/attest, GET /gpu/nodes, POST /render|voice|llm/escrow, POST /render|voice|llm/release, POST /render/refund, GET /render/pricing Related: BoTTube #39 (Render Marketplace) --- node/gpu_render_protocol.py | 496 ++++++++++++++++++++++++++++++ tests/test_gpu_render_protocol.py | 139 +++++++++ 2 files changed, 635 insertions(+) create mode 100644 node/gpu_render_protocol.py create mode 100644 tests/test_gpu_render_protocol.py diff --git a/node/gpu_render_protocol.py b/node/gpu_render_protocol.py new file mode 100644 index 000000000..aff802f56 --- /dev/null +++ b/node/gpu_render_protocol.py @@ -0,0 +1,496 @@ +""" +GPU Render Protocol — Decentralized compute payment layer for RustChain. + +Implements Bounty #30: +- GPU Node Attestation (nvidia_gpu, amd_gpu, apple_gpu) +- Render/Voice/LLM escrow payment endpoints +- Pricing oracle with fair market rate tracking +- SQLite-backed escrow and attestation storage + +Endpoints: + POST /gpu/attest — Register/update GPU attestation + GET /gpu/nodes — List attested GPU nodes + POST /render/escrow — Lock RTC for a render job + POST /render/release — Release escrow on job completion + POST /render/refund — Refund escrow on job failure + POST /voice/escrow — Lock RTC for TTS/STT job + POST /voice/release — Release on audio delivery + POST /llm/escrow — Lock RTC for inference job + POST /llm/release — Release on completion + GET /render/pricing — Get current fair market rates + GET /render/escrow/ — Get escrow status +""" + +import sqlite3 +import time +import uuid +import json +import os +import logging +from functools import wraps + +logger = logging.getLogger("gpu_render_protocol") + +# --------------------------------------------------------------------------- +# Database schema +# --------------------------------------------------------------------------- + +SCHEMA_SQL = """ +CREATE TABLE IF NOT EXISTS render_escrow ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id TEXT UNIQUE NOT NULL, + job_type TEXT NOT NULL CHECK(job_type IN ('render', 'tts', 'stt', 'llm')), + from_wallet TEXT NOT NULL, + to_wallet TEXT NOT NULL, + amount_rtc REAL NOT NULL CHECK(amount_rtc > 0), + status TEXT DEFAULT 'locked' CHECK(status IN ('locked', 'released', 'refunded')), + created_at INTEGER NOT NULL, + released_at INTEGER, + metadata TEXT -- JSON blob for job-specific params +); + +CREATE TABLE IF NOT EXISTS gpu_attestations ( + miner_id TEXT PRIMARY KEY, + gpu_model TEXT NOT NULL, + vram_gb REAL NOT NULL, + cuda_version TEXT, + rocm_version TEXT, + benchmark_score REAL, + device_arch TEXT NOT NULL CHECK(device_arch IN ('nvidia_gpu', 'amd_gpu', 'apple_gpu')), + -- Pricing by job type (RTC per unit) + price_render_minute REAL DEFAULT 0.0, + price_tts_1k_chars REAL DEFAULT 0.0, + price_stt_minute REAL DEFAULT 0.0, + price_llm_1k_tokens REAL DEFAULT 0.0, + -- Capabilities + supports_render INTEGER DEFAULT 1, + supports_tts INTEGER DEFAULT 0, + supports_stt INTEGER DEFAULT 0, + supports_llm INTEGER DEFAULT 0, + tts_models TEXT DEFAULT '[]', -- JSON array + llm_models TEXT DEFAULT '[]', -- JSON array + last_attestation INTEGER NOT NULL, + hardware_fingerprint TEXT, + status TEXT DEFAULT 'active' CHECK(status IN ('active', 'suspended', 'offline')) +); + +CREATE TABLE IF NOT EXISTS pricing_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_type TEXT NOT NULL, + device_arch TEXT NOT NULL, + avg_price REAL NOT NULL, + min_price REAL NOT NULL, + max_price REAL NOT NULL, + sample_count INTEGER NOT NULL, + recorded_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_escrow_status ON render_escrow(status); +CREATE INDEX IF NOT EXISTS idx_escrow_from ON render_escrow(from_wallet); +CREATE INDEX IF NOT EXISTS idx_escrow_to ON render_escrow(to_wallet); +CREATE INDEX IF NOT EXISTS idx_gpu_arch ON gpu_attestations(device_arch); +""" + + +class GPURenderProtocol: + """Core protocol handler for GPU render payments.""" + + def __init__(self, db_path=None): + if db_path is None: + db_path = os.path.join( + os.path.dirname(__file__), "..", "data", "gpu_render.db" + ) + os.makedirs(os.path.dirname(db_path), exist_ok=True) + self.db_path = db_path + self._init_db() + + def _get_conn(self): + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + return conn + + def _init_db(self): + conn = self._get_conn() + conn.executescript(SCHEMA_SQL) + conn.commit() + conn.close() + logger.info("GPU Render Protocol DB initialized at %s", self.db_path) + + # ------------------------------------------------------------------- + # GPU Attestation + # ------------------------------------------------------------------- + + def attest_gpu(self, miner_id: str, gpu_info: dict) -> dict: + """Register or update a GPU node attestation.""" + required = ["gpu_model", "vram_gb", "device_arch"] + for field in required: + if field not in gpu_info: + return {"error": f"Missing required field: {field}"} + + if gpu_info["device_arch"] not in ("nvidia_gpu", "amd_gpu", "apple_gpu"): + return {"error": "device_arch must be nvidia_gpu, amd_gpu, or apple_gpu"} + + # Generate hardware fingerprint from GPU specs + fp_data = f"{miner_id}:{gpu_info['gpu_model']}:{gpu_info['vram_gb']}" + import hashlib + fingerprint = hashlib.sha256(fp_data.encode()).hexdigest()[:16] + + conn = self._get_conn() + try: + conn.execute( + """INSERT INTO gpu_attestations + (miner_id, gpu_model, vram_gb, cuda_version, rocm_version, + benchmark_score, device_arch, price_render_minute, + price_tts_1k_chars, price_stt_minute, price_llm_1k_tokens, + supports_render, supports_tts, supports_stt, supports_llm, + tts_models, llm_models, last_attestation, hardware_fingerprint) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(miner_id) DO UPDATE SET + gpu_model=excluded.gpu_model, + vram_gb=excluded.vram_gb, + cuda_version=excluded.cuda_version, + rocm_version=excluded.rocm_version, + benchmark_score=excluded.benchmark_score, + device_arch=excluded.device_arch, + price_render_minute=excluded.price_render_minute, + price_tts_1k_chars=excluded.price_tts_1k_chars, + price_stt_minute=excluded.price_stt_minute, + price_llm_1k_tokens=excluded.price_llm_1k_tokens, + supports_render=excluded.supports_render, + supports_tts=excluded.supports_tts, + supports_stt=excluded.supports_stt, + supports_llm=excluded.supports_llm, + tts_models=excluded.tts_models, + llm_models=excluded.llm_models, + last_attestation=excluded.last_attestation, + hardware_fingerprint=excluded.hardware_fingerprint, + status='active' + """, + ( + miner_id, + gpu_info["gpu_model"], + gpu_info["vram_gb"], + gpu_info.get("cuda_version"), + gpu_info.get("rocm_version"), + gpu_info.get("benchmark_score"), + gpu_info["device_arch"], + gpu_info.get("price_render_minute", 0.0), + gpu_info.get("price_tts_1k_chars", 0.0), + gpu_info.get("price_stt_minute", 0.0), + gpu_info.get("price_llm_1k_tokens", 0.0), + gpu_info.get("supports_render", 1), + gpu_info.get("supports_tts", 0), + gpu_info.get("supports_stt", 0), + gpu_info.get("supports_llm", 0), + json.dumps(gpu_info.get("tts_models", [])), + json.dumps(gpu_info.get("llm_models", [])), + int(time.time()), + fingerprint, + ), + ) + conn.commit() + return { + "status": "attested", + "miner_id": miner_id, + "fingerprint": fingerprint, + "device_arch": gpu_info["device_arch"], + } + finally: + conn.close() + + def list_gpu_nodes(self, job_type=None, device_arch=None) -> list: + """List active GPU nodes, optionally filtered by capability or arch.""" + conn = self._get_conn() + try: + query = "SELECT * FROM gpu_attestations WHERE status='active'" + params = [] + if job_type: + col = f"supports_{job_type}" + query += f" AND {col}=1" + if device_arch: + query += " AND device_arch=?" + params.append(device_arch) + query += " ORDER BY benchmark_score DESC" + rows = conn.execute(query, params).fetchall() + return [dict(r) for r in rows] + finally: + conn.close() + + # ------------------------------------------------------------------- + # Escrow operations + # ------------------------------------------------------------------- + + def create_escrow(self, job_type: str, from_wallet: str, to_wallet: str, + amount_rtc: float, metadata: dict = None) -> dict: + """Lock RTC in escrow for a compute job.""" + valid_types = ("render", "tts", "stt", "llm") + if job_type not in valid_types: + return {"error": f"job_type must be one of {valid_types}"} + if amount_rtc <= 0: + return {"error": "amount_rtc must be positive"} + if from_wallet == to_wallet: + return {"error": "from_wallet and to_wallet must differ"} + + job_id = f"{job_type}-{uuid.uuid4().hex[:12]}" + conn = self._get_conn() + try: + conn.execute( + """INSERT INTO render_escrow + (job_id, job_type, from_wallet, to_wallet, amount_rtc, + status, created_at, metadata) + VALUES (?,?,?,?,?,'locked',?,?)""", + (job_id, job_type, from_wallet, to_wallet, amount_rtc, + int(time.time()), json.dumps(metadata or {})), + ) + conn.commit() + return { + "status": "locked", + "job_id": job_id, + "job_type": job_type, + "amount_rtc": amount_rtc, + "from_wallet": from_wallet, + "to_wallet": to_wallet, + } + finally: + conn.close() + + def release_escrow(self, job_id: str) -> dict: + """Release escrowed RTC to the GPU provider on job completion.""" + conn = self._get_conn() + try: + row = conn.execute( + "SELECT * FROM render_escrow WHERE job_id=?", (job_id,) + ).fetchone() + if not row: + return {"error": "Job not found"} + if row["status"] != "locked": + return {"error": f"Job already {row['status']}"} + + now = int(time.time()) + conn.execute( + "UPDATE render_escrow SET status='released', released_at=? WHERE job_id=?", + (now, job_id), + ) + conn.commit() + return { + "status": "released", + "job_id": job_id, + "amount_rtc": row["amount_rtc"], + "to_wallet": row["to_wallet"], + "released_at": now, + } + finally: + conn.close() + + def refund_escrow(self, job_id: str) -> dict: + """Refund escrowed RTC to the requester on job failure.""" + conn = self._get_conn() + try: + row = conn.execute( + "SELECT * FROM render_escrow WHERE job_id=?", (job_id,) + ).fetchone() + if not row: + return {"error": "Job not found"} + if row["status"] != "locked": + return {"error": f"Job already {row['status']}"} + + now = int(time.time()) + conn.execute( + "UPDATE render_escrow SET status='refunded', released_at=? WHERE job_id=?", + (now, job_id), + ) + conn.commit() + return { + "status": "refunded", + "job_id": job_id, + "amount_rtc": row["amount_rtc"], + "from_wallet": row["from_wallet"], + "refunded_at": now, + } + finally: + conn.close() + + def get_escrow(self, job_id: str) -> dict: + """Get escrow status for a job.""" + conn = self._get_conn() + try: + row = conn.execute( + "SELECT * FROM render_escrow WHERE job_id=?", (job_id,) + ).fetchone() + if not row: + return {"error": "Job not found"} + result = dict(row) + result["metadata"] = json.loads(result.get("metadata") or "{}") + return result + finally: + conn.close() + + # ------------------------------------------------------------------- + # Pricing Oracle + # ------------------------------------------------------------------- + + def get_fair_market_rates(self, job_type=None) -> dict: + """Calculate fair market rates from active GPU node pricing.""" + conn = self._get_conn() + try: + nodes = conn.execute( + "SELECT * FROM gpu_attestations WHERE status='active'" + ).fetchall() + + if not nodes: + return {"error": "No active GPU nodes", "rates": {}} + + price_fields = { + "render": "price_render_minute", + "tts": "price_tts_1k_chars", + "stt": "price_stt_minute", + "llm": "price_llm_1k_tokens", + } + + types_to_check = [job_type] if job_type else list(price_fields.keys()) + rates = {} + + for jt in types_to_check: + field = price_fields[jt] + prices = [dict(n)[field] for n in nodes if dict(n)[field] > 0] + if prices: + rates[jt] = { + "avg": round(sum(prices) / len(prices), 6), + "min": round(min(prices), 6), + "max": round(max(prices), 6), + "providers": len(prices), + "unit": "RTC/minute" if jt in ("render", "stt") else + "RTC/1k_chars" if jt == "tts" else "RTC/1k_tokens", + } + + # Record to pricing history + conn.execute( + """INSERT INTO pricing_history + (job_type, device_arch, avg_price, min_price, + max_price, sample_count, recorded_at) + VALUES (?,?,?,?,?,?,?)""", + (jt, "all", rates[jt]["avg"], rates[jt]["min"], + rates[jt]["max"], len(prices), int(time.time())), + ) + + conn.commit() + return {"rates": rates, "timestamp": int(time.time())} + finally: + conn.close() + + def detect_price_manipulation(self, job_type: str, proposed_price: float) -> dict: + """Check if a proposed price deviates significantly from market rates.""" + rates = self.get_fair_market_rates(job_type) + if "error" in rates or job_type not in rates.get("rates", {}): + return {"manipulated": False, "reason": "insufficient data"} + + r = rates["rates"][job_type] + # Flag if price is >3x the average or <0.1x the minimum + if proposed_price > r["avg"] * 3: + return {"manipulated": True, "reason": "price_too_high", + "proposed": proposed_price, "market_avg": r["avg"]} + if proposed_price < r["min"] * 0.1: + return {"manipulated": True, "reason": "price_too_low", + "proposed": proposed_price, "market_min": r["min"]} + return {"manipulated": False, "proposed": proposed_price, + "market_avg": r["avg"]} + + +# --------------------------------------------------------------------------- +# Flask route registration (integrates with existing RustChain node) +# --------------------------------------------------------------------------- + +def register_routes(app): + """Register GPU Render Protocol routes with a Flask app.""" + protocol = GPURenderProtocol() + + @app.route("/gpu/attest", methods=["POST"]) + def gpu_attest(): + from flask import request, jsonify + data = request.get_json(force=True) + miner_id = data.get("miner_id") + if not miner_id: + return jsonify({"error": "miner_id required"}), 400 + result = protocol.attest_gpu(miner_id, data) + status_code = 200 if "error" not in result else 400 + return jsonify(result), status_code + + @app.route("/gpu/nodes", methods=["GET"]) + def gpu_nodes(): + from flask import request, jsonify + job_type = request.args.get("job_type") + device_arch = request.args.get("device_arch") + nodes = protocol.list_gpu_nodes(job_type, device_arch) + return jsonify({"nodes": nodes, "count": len(nodes)}) + + @app.route("/render/escrow", methods=["POST"]) + @app.route("/voice/escrow", methods=["POST"]) + @app.route("/llm/escrow", methods=["POST"]) + def create_escrow(): + from flask import request, jsonify + data = request.get_json(force=True) + # Infer job_type from path + path = request.path + if path.startswith("/voice"): + job_type = data.get("job_type", "tts") # tts or stt + elif path.startswith("/llm"): + job_type = "llm" + else: + job_type = data.get("job_type", "render") + + result = protocol.create_escrow( + job_type=job_type, + from_wallet=data.get("from_wallet", ""), + to_wallet=data.get("to_wallet", ""), + amount_rtc=data.get("amount_rtc", 0), + metadata=data.get("metadata"), + ) + status_code = 201 if "error" not in result else 400 + return jsonify(result), status_code + + @app.route("/render/release", methods=["POST"]) + @app.route("/voice/release", methods=["POST"]) + @app.route("/llm/release", methods=["POST"]) + def release_escrow(): + from flask import request, jsonify + data = request.get_json(force=True) + result = protocol.release_escrow(data.get("job_id", "")) + status_code = 200 if "error" not in result else 400 + return jsonify(result), status_code + + @app.route("/render/refund", methods=["POST"]) + def refund_escrow(): + from flask import request, jsonify + data = request.get_json(force=True) + result = protocol.refund_escrow(data.get("job_id", "")) + status_code = 200 if "error" not in result else 400 + return jsonify(result), status_code + + @app.route("/render/escrow/", methods=["GET"]) + def get_escrow(job_id): + from flask import jsonify + result = protocol.get_escrow(job_id) + status_code = 200 if "error" not in result else 404 + return jsonify(result), status_code + + @app.route("/render/pricing", methods=["GET"]) + def get_pricing(): + from flask import request, jsonify + job_type = request.args.get("job_type") + result = protocol.get_fair_market_rates(job_type) + return jsonify(result) + + @app.route("/render/pricing/check", methods=["POST"]) + def check_pricing(): + from flask import request, jsonify + data = request.get_json(force=True) + result = protocol.detect_price_manipulation( + data.get("job_type", "render"), + data.get("price", 0), + ) + return jsonify(result) + + logger.info("GPU Render Protocol routes registered") + return protocol diff --git a/tests/test_gpu_render_protocol.py b/tests/test_gpu_render_protocol.py new file mode 100644 index 000000000..cd6464279 --- /dev/null +++ b/tests/test_gpu_render_protocol.py @@ -0,0 +1,139 @@ +"""Tests for GPU Render Protocol (Bounty #30).""" +import os +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +from node.gpu_render_protocol import GPURenderProtocol + + +class TestGPURenderProtocol(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.mkdtemp() + self.db = os.path.join(self.tmp, "test_gpu.db") + self.proto = GPURenderProtocol(db_path=self.db) + + def test_attest_gpu(self): + result = self.proto.attest_gpu("miner-1", { + "gpu_model": "RTX 4090", + "vram_gb": 24.0, + "device_arch": "nvidia_gpu", + "cuda_version": "12.4", + "benchmark_score": 95.0, + "price_render_minute": 0.5, + "price_llm_1k_tokens": 0.1, + "supports_llm": 1, + "llm_models": ["llama-70b", "mistral-7b"], + }) + self.assertEqual(result["status"], "attested") + self.assertEqual(result["device_arch"], "nvidia_gpu") + self.assertIn("fingerprint", result) + + def test_attest_invalid_arch(self): + result = self.proto.attest_gpu("miner-2", { + "gpu_model": "GTX 1080", + "vram_gb": 8.0, + "device_arch": "invalid", + }) + self.assertIn("error", result) + + def test_list_nodes(self): + self.proto.attest_gpu("miner-1", { + "gpu_model": "RTX 4090", "vram_gb": 24, "device_arch": "nvidia_gpu", + "supports_llm": 1, "benchmark_score": 95, + }) + self.proto.attest_gpu("miner-2", { + "gpu_model": "M2 Ultra", "vram_gb": 192, "device_arch": "apple_gpu", + "supports_llm": 1, "benchmark_score": 80, + }) + all_nodes = self.proto.list_gpu_nodes() + self.assertEqual(len(all_nodes), 2) + + nvidia = self.proto.list_gpu_nodes(device_arch="nvidia_gpu") + self.assertEqual(len(nvidia), 1) + self.assertEqual(nvidia[0]["gpu_model"], "RTX 4090") + + def test_escrow_lifecycle(self): + # Create + result = self.proto.create_escrow("render", "wallet-a", "wallet-b", 10.0) + self.assertEqual(result["status"], "locked") + job_id = result["job_id"] + + # Check + status = self.proto.get_escrow(job_id) + self.assertEqual(status["status"], "locked") + self.assertEqual(status["amount_rtc"], 10.0) + + # Release + release = self.proto.release_escrow(job_id) + self.assertEqual(release["status"], "released") + self.assertEqual(release["amount_rtc"], 10.0) + + # Double release fails + double = self.proto.release_escrow(job_id) + self.assertIn("error", double) + + def test_escrow_refund(self): + result = self.proto.create_escrow("tts", "wallet-a", "wallet-b", 5.0) + job_id = result["job_id"] + + refund = self.proto.refund_escrow(job_id) + self.assertEqual(refund["status"], "refunded") + + def test_escrow_invalid_type(self): + result = self.proto.create_escrow("invalid", "a", "b", 1.0) + self.assertIn("error", result) + + def test_escrow_negative_amount(self): + result = self.proto.create_escrow("llm", "a", "b", -1.0) + self.assertIn("error", result) + + def test_escrow_same_wallet(self): + result = self.proto.create_escrow("render", "same", "same", 1.0) + self.assertIn("error", result) + + def test_pricing_oracle(self): + for i, price in enumerate([0.5, 0.3, 0.7]): + self.proto.attest_gpu(f"miner-{i}", { + "gpu_model": f"GPU-{i}", "vram_gb": 24, "device_arch": "nvidia_gpu", + "price_render_minute": price, + }) + rates = self.proto.get_fair_market_rates("render") + self.assertIn("render", rates["rates"]) + r = rates["rates"]["render"] + self.assertEqual(r["providers"], 3) + self.assertAlmostEqual(r["avg"], 0.5, places=2) + self.assertAlmostEqual(r["min"], 0.3) + self.assertAlmostEqual(r["max"], 0.7) + + def test_price_manipulation_detection(self): + self.proto.attest_gpu("miner-1", { + "gpu_model": "RTX 4090", "vram_gb": 24, "device_arch": "nvidia_gpu", + "price_render_minute": 0.5, + }) + # Normal price + check = self.proto.detect_price_manipulation("render", 0.6) + self.assertFalse(check["manipulated"]) + + # Too high + check = self.proto.detect_price_manipulation("render", 10.0) + self.assertTrue(check["manipulated"]) + self.assertEqual(check["reason"], "price_too_high") + + def test_voice_escrow_types(self): + for jt in ("tts", "stt"): + result = self.proto.create_escrow(jt, "a", "b", 2.0) + self.assertEqual(result["status"], "locked") + self.assertEqual(result["job_type"], jt) + + def test_llm_escrow(self): + result = self.proto.create_escrow("llm", "a", "b", 3.0, + metadata={"model": "llama-70b", "tokens": 5000}) + self.assertEqual(result["status"], "locked") + status = self.proto.get_escrow(result["job_id"]) + self.assertEqual(status["metadata"]["model"], "llama-70b") + + +if __name__ == "__main__": + unittest.main()