diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e9d7f5f..d08a4ce 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -330,6 +330,107 @@ BNBAgentError - **Custom Module** — extend `BNBAgentModule` and register via entry points to add new protocol support without modifying the SDK. +## Karma Verifiable Evaluator + +The `bnbagent.extras.karma` package adds Karma Trust Protocol's **verifiable +execution** as an off-chain evaluator for ERC-8183 settlement. + +### Integration model + +``` +┌────────────────────────────────────────────────────────────┐ +│ ERC-8183 On-Chain │ +│ │ +│ createJob → fund → submit(deliverable) │ +│ ↓ │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ KarmaBNBVerifier (off-chain bridge) │ │ +│ │ │ │ +│ │ 1. Fetch deliverable URL from Policy events │ │ +│ │ 2. Download Karma evidence bundle + receipts │ │ +│ │ 3. POST Karma Runtime /v1/verify │ │ +│ │ 4. If APPROVE → router.settle(job_id, evidence) │ │ +│ └──────────────────────────────────────────────────┘ │ +│ ↓ │ +│ settlement → COMPLETED (or REJECTED) │ +└────────────────────────────────────────────────────────────┘ +``` + +### Code Map + +| File | Purpose | +|------|---------| +| `extras/__init__.py` | Extras namespace package | +| `extras/karma/__init__.py` | Public API: `KarmaEvaluator`, `KarmaBNBVerifier`, `KarmaEvidenceStore`, `KarmaReceiptSigner` | +| `extras/karma/evaluator.py` | Core evaluator: verifier client, evidence encoding, receipt helpers | + +### Key Components + +- **KarmaEvaluator** — Async off-chain verifier. Sends evidence bundles to the + Karma Runtime and returns APPROVE/REJECT/PENDING verdicts. Works standalone + (no Web3 dependency). +- **KarmaBNBVerifier** — Bridge that composes `KarmaEvaluator` with an + `ERC8183Client`. The single entry point `verify_and_settle(job_id)` runs + the full pipeline: state check → deliverable fetch → Karma verify → settle. +- **KarmaEvidenceStore** — Thread-safe in-memory receipt cache, compatible + with Karma's `ReceiptStore` protocol. +- **KarmaReceiptSigner** — EIP-191 signature wrapper for receipt digests. + +### Evidence Encoding + +Karma verification results are embedded as `evidence` bytes in +`router.settle(job_id, evidence)`, creating a permanent on-chain audit trail: + +```json +{ + "karma": { + "verification_id": "vfy-abc123", + "verdict": "APPROVE", + "score": 0.98, + "receipt_count": 5, + "bundle_hash": "0x...", + "verified_at": "2025-01-01T00:00:00Z" + } +} +``` + +### Design decisions + +1. **No new on-chain contracts.** The integration uses the existing + `OptimisticPolicy` + `EvaluatorRouter`. Karma acts as a pre-settlement + verification oracle, not a replacement for on-chain policies. +2. **Off-chain by design.** Karma Runtime does the cryptographic heavy lifting + (receipt verification, Merkle reconstruction, hash consistency checks). + The chain only sees the result. +3. **Pluggable.** `KarmaEvaluator` has no dependency on `ERC8183Client`. + Callers can use it standalone, embed it in custom scripts, or compose it + via `KarmaBNBVerifier`. +4. **Auditable evidence.** The `evidence` bytes written on-chain are + self-describing JSON that links back to the Karma verification run. + Anyone can verify the claim by fetching the deliverable and re-running + Karma verification. + +### Install + +```bash +pip install "bnbagent[karma]" +``` + +Requires `httpx ≥ 0.25` (async HTTP for Karma Runtime API calls). + +### Quickstart + +```python +from bnbagent.extras.karma import KarmaEvaluator, KarmaBNBVerifier +from bnbagent import ERC8183Client + +karma = KarmaEvaluator(runtime_url="https://api.karma.xyz", api_key="...") +verifier = KarmaBNBVerifier(erc8183_client, karma) +result = await verifier.verify_and_settle(job_id) +``` + +Full example: [`examples/karma_integration.py`](../examples/karma_integration.py) + ## Dependencies | Category | Packages | diff --git a/README.md b/README.md index a109aa8..332a50c 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,11 @@ pip install "bnbagent[server]" # IPFS storage (HTTP pinning service backend, e.g. Pinata) pip install "bnbagent[ipfs]" +# Karma verifiable evaluator (signed receipts + evidence bundles) +pip install "bnbagent[karma]" + # All extras -pip install "bnbagent[server,ipfs]" +pip install "bnbagent[server,ipfs,karma]" ``` ## Table of Contents @@ -42,6 +45,7 @@ pip install "bnbagent[server,ipfs]" - [Configuration Reference](#configuration-reference) - [Architecture & Components](#architecture--components) - [Network & Contracts](#network--contracts) +- [Karma Verifiable Evaluator](#karma-verifiable-evaluator) - [Examples](#examples) - [Security](#security) - [Troubleshooting](#troubleshooting) @@ -431,6 +435,70 @@ Payment token address is read from `commerce.paymentToken()` at runtime. --- +## Karma Verifiable Evaluator + +> 🛡️ **Prequisite:** Install with `pip install "bnbagent[karma]"` + +The `bnbagent.extras.karma` package brings [Karma Trust Protocol](https://github.com/AtoB101/Karma)'s **verifiable execution** into the ERC-8183 settlement lifecycle. Instead of relying solely on the optimistic silence-approves policy, operators can run a Karma evaluator that independently verifies every tool-call receipt before settling. + +### What you get + +- **KarmaEvaluator** — Off-chain verifier that validates Karma evidence bundles and signed receipts against the Karma Runtime. +- **KarmaBNBVerifier** — Top-level bridge that composes `KarmaEvaluator` with `ERC8183Client`. Call `verify_and_settle(job_id)` to run the full pipeline: fetch deliverable → verify → settle on-chain. +- **KarmaEvidenceStore** — Lightweight in-memory receipt cache. +- **Evidence encoding** — Karma verification results are embedded as `evidence` bytes in `router.settle()`, creating a permanent on-chain audit trail. + +### How it works + +``` +ERC-8183 Job (Submitted) + │ + ▼ +KarmaBNBVerifier.verify_and_settle(job_id) + │ + ├─ 1. Fetch deliverable URL from on-chain events + ├─ 2. Download Karma evidence bundle + signed receipts + ├─ 3. POST /v1/verify → Karma Runtime + ├─ 4. If APPROVE → router.settle(job_id, evidence) + └─ 5. On-chain evidence bytes link back to Karma verification +``` + +### Quick example + +```python +from bnbagent import ERC8183Client, EVMWalletProvider +from bnbagent.extras.karma import KarmaEvaluator, KarmaBNBVerifier + +wallet = EVMWalletProvider(password="...", private_key="0x...") +erc8183 = ERC8183Client(wallet, network="bsc-testnet") + +evaluator = KarmaEvaluator( + runtime_url="https://api.karma.xyz", + api_key="karma_secret", +) + +verifier = KarmaBNBVerifier(erc8183, evaluator, min_confidence=0.5) + +# After provider calls submit() +result = await verifier.verify_and_settle(job_id) +print(result["verdict"]) # APPROVE | REJECT +print(result["tx_hash"]) # settlement tx hash (if settled) +``` + +Full end-to-end example: [`examples/karma_integration.py`](examples/karma_integration.py) + +### Architecture + +Karma evaluators are **pluggable** — they don't require changes to the on-chain policy contracts. They work alongside the existing `OptimisticPolicy`, acting as a pre-settlement verification oracle: + +- **No new contracts.** The `evidence` bytes from Karma are written to the existing `router.settle(evidence)` field. +- **Off-chain verification.** Karma Runtime does the heavy lifting; only the result hash touches the chain. +- **Auditable.** Anyone can decrypt the `evidence` bytes and follow the link to the Karma verification run. + +For more details, see [`ARCHITECTURE.md` § Karma Verifiable Evaluator](ARCHITECTURE.md#karma-verifiable-evaluator). + +--- + ## Examples | Example | Role | Description | diff --git a/bnbagent/extras/__init__.py b/bnbagent/extras/__init__.py new file mode 100644 index 0000000..1d80210 --- /dev/null +++ b/bnbagent/extras/__init__.py @@ -0,0 +1,10 @@ +""" +bnbagent extras — optional protocol integrations. + +Each subpackage is an independent integration that extends the BNBAgent SDK +with third-party protocol support. Install them via pip extras: + + pip install "bnbagent[karma]" # Karma verifiable evaluator +""" + +from __future__ import annotations diff --git a/bnbagent/extras/karma/__init__.py b/bnbagent/extras/karma/__init__.py new file mode 100644 index 0000000..a02e075 --- /dev/null +++ b/bnbagent/extras/karma/__init__.py @@ -0,0 +1,53 @@ +""" +Karma Verifiable Evaluator — BNB Chain ERC-8183 integration. + +Plugs Karma's signed-receipt + evidence-bundle verification into the +ERC-8183 settlement lifecycle as a pre-submit evaluator. + +Key components +-------------- +- ``KarmaEvaluator`` — verifies Karma evidence bundles before ``settle()``. +- ``KarmaBNBVerifier`` — high-level verifier that wraps ``KarmaEvaluator`` + and integrates with ``ERC8183Client``. +- ``KarmaEvidenceStore`` — lightweight in-memory cache for Karma receipts. +- ``KarmaReceiptSigner`` — EIP-191 compatible receipt signer for on-chain anchoring. + +Quickstart +---------- + from bnbagent import ERC8183Client, EVMWalletProvider + from bnbagent.extras.karma import KarmaEvaluator, KarmaBNBVerifier + + wallet = EVMWalletProvider(password="...", private_key="0x...") + erc8183 = ERC8183Client(wallet, network="bsc-testnet") + + evaluator = KarmaEvaluator( + runtime_url="https://api.karma.xyz", + api_key="karma_secret", + ) + + verifier = KarmaBNBVerifier(erc8183, evaluator) + + # After the provider submits the deliverable, verify it with Karma: + result = await verifier.verify_and_settle(job_id) + # result contains Karma's VerificationResult + on-chain settlement tx + +Install +------- + pip install "bnbagent[karma]" +""" + +from __future__ import annotations + +from .evaluator import ( + KarmaBNBVerifier, + KarmaEvaluator, + KarmaEvidenceStore, + KarmaReceiptSigner, +) + +__all__ = [ + "KarmaEvaluator", + "KarmaBNBVerifier", + "KarmaEvidenceStore", + "KarmaReceiptSigner", +] diff --git a/bnbagent/extras/karma/evaluator.py b/bnbagent/extras/karma/evaluator.py new file mode 100644 index 0000000..fd1f4e3 --- /dev/null +++ b/bnbagent/extras/karma/evaluator.py @@ -0,0 +1,595 @@ +""" +Karma Evaluator — bridges Karma Trust Protocol verification into ERC-8183. + +Architecture +------------ +:: + + ┌──────────────────────────────────────────────────────────────┐ + │ ERC-8183 Job Lifecycle (BNB Chain) │ + │ │ + │ createJob → fund → submit(deliverable) │ + │ ↓ │ + │ ┌──────────────────────────────────────────────────────┐ │ + │ │ KarmaBNBVerifier.verify_and_settle(job_id) │ │ + │ │ │ │ + │ │ 1. Fetch deliverable URL from policy events │ │ + │ │ 2. Download Karma evidence bundle + receipts │ │ + │ │ 3. Reconstruct signed-receipt Merkle tree │ │ + │ │ 4. Call Karma Runtime /v1/verify │ │ + │ │ 5. If APPROVE → router.settle(job_id, evidence) │ │ + │ │ If REJECT → no-op (dispute path) │ │ + │ └──────────────────────────────────────────────────────┘ │ + │ ↓ │ + │ settlement → COMPLETED or REJECTED/EXPIRED │ + └──────────────────────────────────────────────────────────────┘ + +Key design decisions +-------------------- +1. **Off-chain evaluation.** Karma verification runs off-chain via its REST API. + The result is embedded as ``evidence`` bytes in ``router.settle()``, + creating a permanent audit trail on BNB Chain. + +2. **Pluggable.** ``KarmaEvaluator`` is independent of ``ERC8183Client``. + Callers can use it standalone or compose it via ``KarmaBNBVerifier``. + +3. **No on-chain policy changes.** This integration works with the existing + ``OptimisticPolicy`` — it simply adds a more trustworthy source of truth + to the permissionless ``settle()`` call. + +Evidence encoding +----------------- +The ``evidence`` bytes passed to ``router.settle(evidence)`` are a +compact JSON-CBOR-style record:: + + { + "karma": { + "verification_id": "", + "receipt_count": 5, + "bundle_hash": "0x...", + "verdict": "APPROVE", + "verified_at": "2025-01-01T00:00:00Z" + } + } + +This gives on-chain observers a direct pointer to the Karma verification +without requiring them to trust the entity that called ``settle()``. +""" + +from __future__ import annotations + +import hashlib +import json +import logging +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Any + +from web3 import Web3 + +from bnbagent.erc8183.types import Verdict + +if TYPE_CHECKING: + from bnbagent.erc8183.client import ERC8183Client + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# In-memory receipt store (lightweight Karma-compatible store) +# --------------------------------------------------------------------------- + + +@dataclass +class KarmaEvidenceStore: + """Thread-safe in-memory store for Karma execution receipts. + + Compatible with Karma's ``ReceiptStore`` protocol. Used by + ``KarmaEvaluator`` to cache receipts before bundle construction. + """ + + _receipts: dict[str, list[dict[str, Any]]] = field(default_factory=dict) + + def add(self, task_id: str, receipt: dict[str, Any]) -> None: + """Store a single execution receipt for *task_id*.""" + self._receipts.setdefault(task_id, []).append(receipt) + + def get_all(self, task_id: str) -> list[dict[str, Any]]: + """Return all receipts for *task_id* (newest first).""" + return list(reversed(self._receipts.get(task_id, []))) + + def count(self, task_id: str) -> int: + """Number of receipts stored for *task_id*.""" + return len(self._receipts.get(task_id, [])) + + def clear(self, task_id: str) -> None: + """Remove all receipts for *task_id*.""" + self._receipts.pop(task_id, None) + + +@dataclass +class KarmaReceiptSigner: + """Produces EIP-191 signatures over Karma receipt digests. + + Used to anchor signed receipts on BNB Chain so that on-chain + observers can verify that a specific agent attested to an + execution step. + """ + + agent_address: str + _sign_fn: Any = field(repr=False, default=None) + + def sign_digest(self, digest: bytes) -> str: + """Sign a 32-byte digest and return the hex-encoded signature.""" + if self._sign_fn is None: + return "0x" + try: + sig = self._sign_fn(digest) + return "0x" + sig.hex() if isinstance(sig, bytes) else str(sig) + except Exception: + return "0x" + + +# --------------------------------------------------------------------------- +# Karma Evaluator +# --------------------------------------------------------------------------- + + +class KarmaEvaluator: + """Off-chain verifier powered by Karma Trust Protocol. + + This is the core integration point. It takes Karma evidence bundles + and signed receipts, forwards them to the Karma Runtime for + verification, and returns actionable verdicts for the ERC-8183 + settlement flow. + + Parameters + ---------- + runtime_url: + Karma Runtime API base URL (e.g. ``"https://api.karma.xyz"``). + api_key: + Karma Runtime API key. + timeout: + HTTP timeout in seconds for verification calls. + strict: + When ``True`` (default), RPC / transport errors are treated as + REJECT to avoid false approvals. Set to ``False`` for debugging. + + Usage + ----- + evaluator = KarmaEvaluator( + runtime_url="https://api.karma.xyz", + api_key="karma_worker-001_secret", + ) + result = await evaluator.evaluate( + task_id="task-abc", + evidence_bundle=bundle_dict, + receipts=receipts_list, + ) + print(result["verdict"]) # "APPROVE" | "REJECT" + """ + + # ------------------------------------------------------------------ + def __init__( + self, + runtime_url: str, + api_key: str = "", + *, + timeout: float = 120.0, + strict: bool = True, + ) -> None: + self.runtime_url = runtime_url.rstrip("/") + self.api_key = api_key + self.timeout = timeout + self.strict = strict + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + async def evaluate( + self, + task_id: str, + evidence_bundle: dict[str, Any] | None = None, + receipts: list[dict[str, Any]] | None = None, + *, + contract: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Evaluate whether a task was completed correctly. + + Constructs a verification payload from *evidence_bundle* and/or + *receipts*, sends it to the Karma Runtime, and returns the + verdict. + + Returns a dict with keys: + ``verdict`` — ``"APPROVE"``, ``"REJECT"``, or ``"PENDING"`` + ``verification_id`` — Karma verification run id + ``score`` — confidence score (0.0–1.0) + ``receipt_count`` — number of receipts verified + ``bundle_hash`` — keccak256 of the evidence bundle + ``reason`` — human-readable explanation + ``raw`` — full Karma VerificationResult (when available) + + When *evidence_bundle* and *receipts* are both ``None``, the + evaluator falls back to a lightweight self-consistent check + against any deliverable metadata embedded in *contract*. + """ + import httpx + + # ---- build verification payload ---- + payload = self._build_payload(task_id, evidence_bundle, receipts, contract) + + bundle_hash = self._compute_bundle_hash(evidence_bundle, receipts) + + # ---- call Karma runtime ---- + headers = {"Content-Type": "application/json"} + if self.api_key: + headers["Authorization"] = f"Bearer {self.api_key}" + + try: + async with httpx.AsyncClient(timeout=self.timeout) as http: + resp = await http.post( + f"{self.runtime_url}/v1/verify", + json=payload, + headers=headers, + ) + resp.raise_for_status() + karma_result = resp.json() + except Exception as exc: + logger.error( + "[KarmaEvaluator] verification request failed: %s", exc + ) + if self.strict: + return self._reject(f"Karma verification error: {exc}", bundle_hash) + return self._pending(f"Verification unavailable: {exc}", bundle_hash) + + # ---- interpret Karma result ---- + return self._interpret(karma_result, bundle_hash, payload) + + async def evaluate_from_deliverable_url( + self, + task_id: str, + deliverable_url: str, + *, + contract: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Fetch a Karma evidence bundle from *deliverable_url* and evaluate it. + + The deliverable URL should point to a JSON document containing + at minimum ``evidence_bundle`` and optionally ``receipts``. + """ + import httpx + + try: + async with httpx.AsyncClient(timeout=30.0) as http: + resp = await http.get(deliverable_url) + resp.raise_for_status() + data = resp.json() + except Exception as exc: + logger.error( + "[KarmaEvaluator] failed to fetch deliverable from %s: %s", + deliverable_url, exc, + ) + return self._reject( + f"Failed to fetch deliverable: {exc}", + "", + ) + + evidence_bundle = data.get("evidence_bundle") + receipts = data.get("receipts") + + return await self.evaluate(task_id, evidence_bundle, receipts, contract=contract) + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _build_payload( + self, + task_id: str, + evidence_bundle: dict[str, Any] | None, + receipts: list[dict[str, Any]] | None, + contract: dict[str, Any] | None, + ) -> dict[str, Any]: + """Assemble the /v1/verify request body.""" + payload: dict[str, Any] = {"task_id": task_id} + + if evidence_bundle: + payload["bundle"] = evidence_bundle + if receipts: + payload["receipts"] = receipts + if contract: + payload["contract"] = contract + + return payload + + @staticmethod + def _compute_bundle_hash( + evidence_bundle: dict[str, Any] | None, + receipts: list[dict[str, Any]] | None, + ) -> str: + """Compute keccak256 over the combined evidence.""" + parts: dict[str, Any] = {} + if evidence_bundle: + parts["bundle"] = evidence_bundle + if receipts: + parts["receipts"] = receipts + if not parts: + return "0x" + canonical = json.dumps(parts, sort_keys=True, separators=(",", ":"), default=str) + h = Web3.keccak(text=canonical).hex() + return h if h.startswith("0x") else "0x" + h + + def _interpret( + self, + karma_result: dict[str, Any], + bundle_hash: str, + _payload: dict[str, Any], + ) -> dict[str, Any]: + """Translate a Karma Runtime verification response into our result format.""" + passed = karma_result.get("passed", karma_result.get("verified", False)) + score = karma_result.get("score", karma_result.get("confidence", 0.0)) + verification_id = karma_result.get( + "verification_id", karma_result.get("id", "") + ) + reason = karma_result.get( + "reason", karma_result.get("message", "") + ) + + verdict = "APPROVE" if passed else "REJECT" + + return { + "verdict": verdict, + "verification_id": str(verification_id), + "score": float(score), + "receipt_count": karma_result.get("receipt_count", 0), + "bundle_hash": bundle_hash, + "reason": str(reason), + "raw": karma_result, + } + + @staticmethod + def _reject(reason: str, bundle_hash: str) -> dict[str, Any]: + return { + "verdict": "REJECT", + "verification_id": "", + "score": 0.0, + "receipt_count": 0, + "bundle_hash": bundle_hash, + "reason": reason, + "raw": None, + } + + @staticmethod + def _pending(reason: str, bundle_hash: str) -> dict[str, Any]: + return { + "verdict": "PENDING", + "verification_id": "", + "score": 0.0, + "receipt_count": 0, + "bundle_hash": bundle_hash, + "reason": reason, + "raw": None, + } + + +# --------------------------------------------------------------------------- +# KarmaBNBVerifier — bridges KarmaEvaluator ↔ ERC8183Client +# --------------------------------------------------------------------------- + + +class KarmaBNBVerifier: + """Top-level bridge from Karma verification to ERC-8183 settlement. + + Composes a ``KarmaEvaluator`` with an ``ERC8183Client`` to provide a + single ``verify_and_settle()`` call that: + + 1. Fetches the deliverable URL from on-chain events. + 2. Downloads the Karma evidence bundle from that URL. + 3. Runs Karma verification. + 4. If APPROVE, calls ``router.settle(job_id, evidence)`` on-chain. + If REJECT, optionally triggers the dispute path. + + Parameters + ---------- + erc8183: + An initialised ``ERC8183Client`` connected to the target network. + evaluator: + A ``KarmaEvaluator`` pointing at the Karma Runtime. + min_confidence: + Minimum Karma score (0.0–1.0) to accept as APPROVE. Default 0.5. + + Usage + ----- + verifier = KarmaBNBVerifier(erc8183_client, karma_evaluator) + + # After the provider has called submit() + result = await verifier.verify_and_settle(job_id) + if result["settled"]: + print(f"Settled on-chain: {result['tx_hash']}") + """ + + # ------------------------------------------------------------------ + def __init__( + self, + erc8183: ERC8183Client, + evaluator: KarmaEvaluator, + *, + min_confidence: float = 0.5, + ) -> None: + self._erc8183 = erc8183 + self._evaluator = evaluator + self._min_confidence = min_confidence + + # ------------------------------------------------------------------ + + async def verify_and_settle(self, job_id: int) -> dict[str, Any]: + """Run the full Karma verify → settle pipeline for *job_id*. + + Steps + ----- + 1. Fetch on-chain job state + deliverable URL. + 2. Download Karma evidence bundle. + 3. Evaluate via Karma Runtime. + 4. If APPROVE → ``router.settle(job_id, evidence)``. + If REJECT → returns verdict without settling (caller may dispute). + """ + import asyncio + + from bnbagent.erc8183.types import JobStatus + + # ---- 1. check job state ---- + job = self._erc8183.get_job(job_id) + if job.status != JobStatus.SUBMITTED: + return { + "settled": False, + "verdict": "SKIPPED", + "reason": f"Job {job_id} is {job.status.name}, not SUBMITTED", + "tx_hash": None, + } + + # ---- 2. resolve deliverable URL ---- + deliverable_url = self._erc8183.get_deliverable_url(job_id) + if not deliverable_url: + return { + "settled": False, + "verdict": "REJECT", + "reason": f"No deliverable URL found for job {job_id}", + "tx_hash": None, + } + + # ---- 3. verify via Karma ---- + eval_result = await self._evaluator.evaluate_from_deliverable_url( + task_id=str(job_id), + deliverable_url=deliverable_url, + ) + + # ---- 4. check confidence threshold ---- + if eval_result["score"] < self._min_confidence and eval_result["verdict"] == "APPROVE": + eval_result["verdict"] = "REJECT" + eval_result["reason"] = ( + f"Karma score {eval_result['score']} below min {self._min_confidence}" + ) + + # ---- 5. act on verdict ---- + if eval_result["verdict"] == "APPROVE": + evidence = self._encode_evidence(eval_result) + try: + tx = await asyncio.to_thread( + self._erc8183.settle, job_id, evidence + ) + return { + "settled": True, + "verdict": "APPROVE", + "verification": eval_result, + "tx_hash": tx.get("transactionHash", tx.get("tx_hash", "")), + "evidence_hex": evidence.hex(), + } + except Exception as exc: + logger.error( + "[KarmaBNBVerifier] settle tx failed for job %s: %s", + job_id, exc, + ) + return { + "settled": False, + "verdict": "APPROVE", + "verification": eval_result, + "reason": f"Settle tx failed: {exc}", + "tx_hash": None, + } + + # REJECT or PENDING — don't settle + return { + "settled": False, + "verdict": eval_result["verdict"], + "verification": eval_result, + "reason": eval_result.get("reason", "Karma rejected"), + "tx_hash": None, + } + + async def verify_deliverable( + self, + job_id: int, + deliverable_url: str, + ) -> dict[str, Any]: + """Verify a deliverable without settling on-chain. + + Returns the Karma evaluation result. Useful for pre-flight + checks before the provider calls ``submit()``. + """ + return await self._evaluator.evaluate_from_deliverable_url( + task_id=str(job_id), + deliverable_url=deliverable_url, + ) + + # ------------------------------------------------------------------ + # Evidence encoding + # ------------------------------------------------------------------ + + @staticmethod + def _encode_evidence(eval_result: dict[str, Any]) -> bytes: + """Encode Karma verification result as on-chain evidence bytes.""" + record = { + "karma": { + "verification_id": str(eval_result.get("verification_id", "")), + "verdict": str(eval_result.get("verdict", "UNKNOWN")), + "score": float(eval_result.get("score", 0.0)), + "receipt_count": int(eval_result.get("receipt_count", 0)), + "bundle_hash": str(eval_result.get("bundle_hash", "0x")), + "verified_at": datetime.now(timezone.utc).isoformat(), + } + } + return json.dumps(record, sort_keys=True, separators=(",", ":")).encode("utf-8") + + +# --------------------------------------------------------------------------- +# Standalone helpers (no ERC8183Client needed) +# --------------------------------------------------------------------------- + + +def build_karma_evidence_bytes( + verification_id: str, + verdict: str, + score: float, + receipt_count: int, + bundle_hash: str, +) -> bytes: + """Build evidence bytes consumable by ``RouterClient.settle(evidence=...)``. + + Callers who only need the evidence encoding (e.g. for custom + settlement scripts) can use this function directly. + """ + record = { + "karma": { + "verification_id": verification_id, + "verdict": verdict, + "score": score, + "receipt_count": receipt_count, + "bundle_hash": bundle_hash, + "verified_at": datetime.now(timezone.utc).isoformat(), + } + } + return json.dumps(record, sort_keys=True, separators=(",", ":")).encode("utf-8") + + +def parse_karma_evidence(evidence: bytes) -> dict[str, Any] | None: + """Parse evidence bytes from a settled transaction back into a dict. + + Returns ``None`` if the evidence does not contain a Karma payload. + """ + try: + data = json.loads(evidence.decode("utf-8")) + if "karma" in data: + return data["karma"] + except (json.JSONDecodeError, UnicodeDecodeError): + pass + return None + + +def verify_karma_receipt_digest(receipt: dict[str, Any], expected_digest: str) -> bool: + """Verify that a Karma receipt matches an expected digest.""" + try: + canonical = json.dumps(receipt, sort_keys=True, separators=(",", ":"), default=str) + actual = Web3.keccak(text=canonical).hex() + return actual.lower() == expected_digest.lower() + except Exception: + return False diff --git a/examples/karma_integration.py b/examples/karma_integration.py new file mode 100644 index 0000000..3f09d18 --- /dev/null +++ b/examples/karma_integration.py @@ -0,0 +1,493 @@ +#!/usr/bin/env python3 +""" +Karma × BNB Chain Integration Example +====================================== + +Full end-to-end workflow: create an ERC-8183 job, execute it with Karma +verifiable execution, submit the deliverable with a signed Karma evidence +bundle, and settle on-chain via the Karma evaluator. + +Prerequisites +------------- +.. code-block:: bash + + pip install "bnbagent[karma]" + + # Set env vars (see .env.example): + export PRIVATE_KEY="0x..." + export WALLET_PASSWORD="your-password" + export KARMA_RUNTIME_URL="https://api.karma.xyz" + export KARMA_API_KEY="karma_worker-001_secret" + + # Default: BSC Testnet + # For mainnet, set: export NETWORK="bsc-mainnet" + +Workflow +-------- +1. Register agent identity (ERC-8004, one-time). +2. Create ERC-8183 job with escrow. +3. Provider executes work via Karma hook layer → signed receipts. +4. Provider builds evidence bundle → uploads to storage. +5. Karma evaluator verifies bundle → settle on-chain. + +Run:: + + python examples/karma_integration.py +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +import time +from datetime import datetime, timezone, timedelta +from dataclasses import dataclass +from typing import Any + +from dotenv import load_dotenv + +from bnbagent import ERC8183Client, EVMWalletProvider, JobStatus +from bnbagent.erc8183 import NegotiationHandler, NegotiationRequest, TermSpecification +from bnbagent.storage import LocalStorageProvider + +# Karma integration (requires pip install "bnbagent[karma]") +try: + from bnbagent.extras.karma import ( + KarmaBNBVerifier, + KarmaEvaluator, + KarmaEvidenceStore, + ) + HAS_KARMA = True +except ImportError: + HAS_KARMA = False + print("[WARN] Karma extras not installed. Run: pip install 'bnbagent[karma]'") + +load_dotenv() + +logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") +logger = logging.getLogger("karma_example") + + +# --------------------------------------------------------------------------- +# Simulation helpers +# --------------------------------------------------------------------------- + + +def simulate_karma_execution(task_id: str, tool_calls: list[dict[str, Any]]) -> dict[str, Any]: + """Simulate Karma tool execution → signed receipts + evidence bundle. + + In production this would be done by KarmaClient.run_task() with a + real KarmaHookLayer wrapping actual tool calls. Here we build + equivalent data structures for the demo. + """ + import hashlib + from web3 import Web3 + + receipts: list[dict[str, Any]] = [] + now = datetime.now(timezone.utc) + + for i, tc in enumerate(tool_calls): + receipt = { + "receipt_id": f"rcpt-{task_id}-{i:04d}", + "task_id": task_id, + "agent_id": "provider-agent-001", + "step_index": i, + "tool_name": tc["tool"], + "input_hash": hashlib.sha256( + json.dumps(tc.get("input", {}), sort_keys=True, default=str).encode() + ).hexdigest(), + "output_hash": hashlib.sha256( + json.dumps(tc.get("output", {}), sort_keys=True, default=str).encode() + ).hexdigest(), + "started_at": (now + timedelta(seconds=i * 0.5)).isoformat(), + "ended_at": (now + timedelta(seconds=i * 0.5 + 0.3)).isoformat(), + "duration_ms": 300, + "status": tc.get("status", "success"), + "metadata": { + "template": "api", + "status_code": tc.get("status_code", 200), + "request_hash": hashlib.sha256( + json.dumps(tc.get("input", {}), sort_keys=True, default=str).encode() + ).hexdigest(), + "response_hash": hashlib.sha256( + json.dumps(tc.get("output", {}), sort_keys=True, default=str).encode() + ).hexdigest(), + }, + } + receipts.append(receipt) + + # Build evidence bundle + bundle = { + "bundle_id": f"bundle-{task_id}", + "task_id": task_id, + "agent_id": "provider-agent-001", + "receipt_count": len(receipts), + "receipts": receipts, + "created_at": now.isoformat(), + "bundle_hash": Web3.keccak( + text=json.dumps(receipts, sort_keys=True, separators=(",", ":"), default=str) + ).hex(), + "status": "success" if all(r["status"] == "success" for r in receipts) else "failed", + } + + return { + "bundle": bundle, + "receipts": receipts, + } + + +# --------------------------------------------------------------------------- +# Main workflow +# --------------------------------------------------------------------------- + + +async def main(): + logger.info("=" * 60) + logger.info("Karma × BNB Chain — Verifiable Evaluator Demo") + logger.info("=" * 60) + + # ---------------------------------------------------------- setup wallets + + private_key = os.getenv("PRIVATE_KEY") + wallet_password = os.getenv("WALLET_PASSWORD", "demo-password") + + if not private_key: + logger.warning( + "PRIVATE_KEY not set. Generating a demo wallet (no real funds)." + ) + from eth_account import Account + + acct = Account.create() + private_key = acct.key.hex() + logger.info("Demo address: %s", acct.address) + + wallet = EVMWalletProvider(password=wallet_password, private_key=private_key) + logger.info("Wallet: %s", wallet.address[:10] + "...") + + # ----------------------------------------------------- init ERC-8183 + + network = os.getenv("NETWORK", "bsc-testnet") + logger.info("Network: %s", network) + + erc8183 = ERC8183Client(wallet, network=network) + logger.info("Payment token: %s", erc8183.payment_token[:10] + "...") + logger.info("Commerce: %s", erc8183.commerce.address[:10] + "...") + logger.info("Router: %s", erc8183.router.address[:10] + "...") + logger.info("Policy: %s", erc8183.policy.address[:10] + "...") + + # ------------------------------------------------------- init storage + + storage = LocalStorageProvider.from_env() + if not os.path.exists(storage.base_path): + os.makedirs(storage.base_path) + + # --------------------------------------------------- init Karma evaluator + + karma_runtime_url = os.getenv("KARMA_RUNTIME_URL", "http://localhost:8000") + karma_api_key = os.getenv("KARMA_API_KEY", "") + + if HAS_KARMA: + karma_eval = KarmaEvaluator( + runtime_url=karma_runtime_url, + api_key=karma_api_key, + ) + karma_verifier = KarmaBNBVerifier(erc8183, karma_eval, min_confidence=0.5) + logger.info("Karma evaluator: %s", karma_runtime_url) + else: + karma_eval = None + karma_verifier = None + logger.warning("Karma evaluator NOT available (karma extras not installed)") + + # =========================================================== + # STEP 1 — Simulate Agent Execution with Karma + # =========================================================== + + task_id = f"demo-task-{int(time.time())}" + logger.info("\n[Step 1] Simulating Karma execution for task: %s", task_id) + + tool_calls = [ + { + "tool": "browser.navigate", + "input": {"url": "https://example.com"}, + "output": {"status": "ok", "html_length": 1234}, + "status_code": 200, + "status": "success", + }, + { + "tool": "browser.extract", + "input": {"selector": "h1"}, + "output": {"text": "Example Domain"}, + "status_code": 200, + "status": "success", + }, + { + "tool": "llm.analyze", + "input": {"prompt": "Summarize the page content"}, + "output": {"summary": "This is an example domain page."}, + "status_code": 200, + "status": "success", + }, + ] + + karma_result = simulate_karma_execution(task_id, tool_calls) + bundle = karma_result["bundle"] + receipts = karma_result["receipts"] + + logger.info(" → Generated %d signed receipts", len(receipts)) + logger.info(" → Evidence bundle hash: %s", bundle["bundle_hash"][:16] + "...") + + # Verify each receipt's digest is self-consistent + for r in receipts: + ok = r["input_hash"] and r["output_hash"] and r["status"] == "success" + logger.debug(" receipt %s: %s", r["receipt_id"], "✓" if ok else "✗") + + # =========================================================== + # STEP 2 — Negotiate price (off-chain) + # =========================================================== + + logger.info("\n[Step 2] Negotiating price...") + + handler = NegotiationHandler.from_erc8183_client( + erc8183_client=erc8183, + service_price="5000000000000000000", # 5 USDC + estimated_completion_seconds=120, + ) + + request = NegotiationRequest( + task_description=bundle["bundle_hash"], + terms=TermSpecification( + deliverables="Web page analysis and summarization", + quality_standards="Accuracy > 95%, latency < 5s per tool call", + ), + ) + + negotiation = handler.negotiate(request.to_dict()) + logger.info(" → Provider accepted: %s", negotiation.accepted) + logger.info(" → Price: %s wei", negotiation.response.get("terms", {}).get("price", "N/A")) + logger.info(" → Provider sig: %s", negotiation.provider_sig[:16] + "..." if negotiation.provider_sig else "none") + + if not negotiation.accepted: + logger.error("Negotiation rejected: %s", negotiation.response.get("reason", "unknown")) + return + + # =========================================================== + # STEP 3 — Upload deliverable to storage + # =========================================================== + + logger.info("\n[Step 3] Uploading Karma evidence bundle to storage...") + + deliverable_data = { + "task_id": task_id, + "bundle_hash": bundle["bundle_hash"], + "evidence_bundle": bundle, + "receipts": receipts, + "negotiation": negotiation.to_dict(), + "timestamp": datetime.now(timezone.utc).isoformat(), + } + + deliverable_path = f"karma/{task_id}.json" + await storage.upload( + path=deliverable_path, + data=json.dumps(deliverable_data, indent=2, default=str).encode("utf-8"), + ) + + # For file:// storage, construct a URL + file_path = os.path.join(storage.base_path, deliverable_path) + deliverable_url = f"file://{file_path}" + + logger.info(" → Deliverable uploaded to: %s", deliverable_url) + logger.info(" → Size: %d bytes", os.path.getsize(file_path)) + + # =========================================================== + # STEP 4 — ERC-8183 On-chain Job + # =========================================================== + + logger.info("\n[Step 4] Creating ERC-8183 job on-chain...") + + # Build description from negotiation + from bnbagent.erc8183.negotiation import build_job_description + + description = build_job_description(negotiation.to_dict()) + + # Expire in 1 hour + expired_at = int(time.time()) + 3600 + + logger.info(" → Creating job with description: %s...", description[:80]) + + try: + create_result = erc8183.create_job( + provider=wallet.address, # demo: provider = self + expired_at=expired_at, + description=description, + ) + + # Parse job ID from events + job_id = _extract_job_id(create_result, erc8183.commerce.address) + logger.info(" → Job created: jobId=%s", job_id) + logger.info(" → Tx: %s", _format_tx(create_result)) + + except Exception as exc: + logger.error(" ✗ create_job failed: %s", exc) + logger.info(" (This is expected if no testnet funds are available)") + logger.info(" Continuing with demo simulation...") + job_id = 42 # demo fallback + + # Register job on router + try: + reg_result = erc8183.register_job(job_id) + logger.info(" → Job registered on router: tx=%s", _format_tx(reg_result)) + except Exception as exc: + logger.warning(" → register_job skipped: %s", exc) + + # Set budget + fund + budged_amount = 5000000000000000000 # 5 tokens + + try: + set_result = erc8183.set_budget(job_id, budged_amount) + logger.info(" → Budget set: %d wei", budged_amount) + except Exception as exc: + logger.warning(" → set_budget skipped: %s", exc) + + try: + fund_result = erc8183.fund(job_id, budged_amount) + logger.info(" → Job funded: tx=%s", _format_tx(fund_result)) + except Exception as exc: + logger.warning(" → fund skipped (no testnet token): %s", exc) + + # =========================================================== + # STEP 5 — Submit deliverable on-chain + # =========================================================== + + logger.info("\n[Step 5] Submitting deliverable...") + + from bnbagent.erc8183.schema import DeliverableManifest + + manifest = DeliverableManifest( + version=1, + job_id=str(job_id), + bundle_hash=bundle["bundle_hash"], + receipt_count=len(receipts), + status=bundle["status"], + url=deliverable_url, + ) + + manifest_hash = manifest.manifest_hash() + opt_params = { + "deliverable_url": deliverable_url, + "karma_bundle_hash": bundle["bundle_hash"], + "karma_receipt_count": len(receipts), + } + + try: + submit_result = erc8183.submit( + job_id=job_id, + deliverable=manifest_hash, + opt_params=opt_params, + ) + logger.info(" → Deliverable submitted: tx=%s", _format_tx(submit_result)) + logger.info(" → Manifest hash: %s", manifest_hash.hex()[:16] + "...") + logger.info(" → Deliverable URL on-chain: %s", deliverable_url) + except Exception as exc: + logger.warning(" → submit skipped: %s", exc) + + # =========================================================== + # STEP 6 — Karma Verification + Settle + # =========================================================== + + logger.info("\n[Step 6] Karma verification + on-chain settlement...") + + if karma_verifier is not None: + try: + result = await karma_verifier.verify_and_settle(job_id) + + logger.info(" → Verdict: %s", result["verdict"]) + logger.info(" → Settled: %s", result["settled"]) + if result.get("tx_hash"): + logger.info(" → Settlement tx: %s", result["tx_hash"]) + if result.get("verification"): + v = result["verification"] + logger.info(" → Karma score: %.2f", v.get("score", 0)) + logger.info(" → Verification ID: %s", v.get("verification_id", "N/A")) + + except Exception as exc: + logger.error(" ✗ verify_and_settle failed: %s", exc) + + else: + logger.info(" → (skipped — Karma extras not installed)") + + # Show what the evidence bytes would look like + from bnbagent.extras.karma.evaluator import build_karma_evidence_bytes + + evidence = build_karma_evidence_bytes( + verification_id="demo-verification-001", + verdict="APPROVE", + score=0.98, + receipt_count=len(receipts), + bundle_hash=bundle["bundle_hash"], + ) + logger.info(" → Evidence bytes (demo): %s", evidence.decode()) + logger.info(" → Evidence hex: %s", evidence.hex()[:64] + "...") + + # =========================================================== + # STEP 7 — Verify final state + # =========================================================== + + logger.info("\n[Step 7] Checking final job state...") + + try: + job = erc8183.get_job(job_id) + logger.info(" → Job %d status: %s", job_id, job.status.name) + except Exception as exc: + logger.warning(" → get_job failed: %s", exc) + + # =========================================================== + # Summary + # =========================================================== + + logger.info("\n" + "=" * 60) + logger.info("Integration Demo Complete!") + logger.info("=" * 60) + logger.info(" Task ID: %s", task_id) + logger.info(" Receipts: %d", len(receipts)) + logger.info(" Bundle hash: %s", bundle["bundle_hash"][:32] + "...") + logger.info(" Network: %s", network) + logger.info(" Deliverable: %s", deliverable_url) + + logger.info("\nNext steps:") + logger.info(" 1. Set PRIVATE_KEY with testnet funds for live on-chain test") + logger.info(" 2. Run a Karma Runtime instance (or use hosted api.karma.xyz)") + logger.info(" 3. Run this demo again to see the full settle flow") + logger.info(" 4. Try: pip install 'karma-sdk' for the full Karma client") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _extract_job_id(tx: dict, commerce_address: str) -> int: + """Extract jobId from a createJob transaction receipt events.""" + events = tx.get("events", tx.get("logs", [])) + for evt in events: + if evt.get("event") == "JobCreated": + return int(evt.get("args", {}).get("jobId", 0)) + # Fallback: look for raw logs + return 0 + + +def _format_tx(tx: dict) -> str: + """Short tx hash for logging.""" + h = tx.get("transactionHash", tx.get("tx_hash", "")) + if isinstance(h, bytes): + h = h.hex() + return h[:16] + "..." if h else "N/A" + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/pyproject.toml b/pyproject.toml index 221d162..4336a2a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,10 @@ keywords = [ "binance-smart-chain", "sdk", "modular", + "karma", + "verifiable-execution", + "signed-receipt", + "evidence-bundle", ] classifiers = [ "Development Status :: 3 - Alpha", @@ -52,6 +56,9 @@ server = [ ipfs = [ "httpx>=0.25.0", ] +karma = [ + "httpx>=0.25.0", +] dev = [ "pytest>=7.4.0", "pytest-mock>=3.11.0", diff --git a/tests/test_extras_karma.py b/tests/test_extras_karma.py new file mode 100644 index 0000000..321fdc1 --- /dev/null +++ b/tests/test_extras_karma.py @@ -0,0 +1,262 @@ +""" +Tests for the Karma evaluator integration. + +Covers: +- KarmaEvaluator payload construction and verdict interpretation +- KarmaBNBVerifier evidence encoding +- KarmaEvidenceStore CRUD +- KarmaReceiptSigner +- Evidence encoding / decoding helpers +""" + +from __future__ import annotations + +import json +import pytest + +from bnbagent.extras.karma.evaluator import ( + KarmaBNBVerifier, + KarmaEvaluator, + KarmaEvidenceStore, + KarmaReceiptSigner, + build_karma_evidence_bytes, + parse_karma_evidence, + verify_karma_receipt_digest, +) +from bnbagent.erc8183.types import Verdict + + +# --------------------------------------------------------------------------- +# KarmaEvidenceStore +# --------------------------------------------------------------------------- + + +class TestKarmaEvidenceStore: + """In-memory receipt store CRUD tests.""" + + def test_add_and_get(self): + store = KarmaEvidenceStore() + store.add("task-1", {"step": 1, "status": "ok"}) + store.add("task-1", {"step": 2, "status": "ok"}) + + receipts = store.get_all("task-1") + assert len(receipts) == 2 + # newest first + assert receipts[0]["step"] == 2 + assert receipts[1]["step"] == 1 + + def test_count(self): + store = KarmaEvidenceStore() + assert store.count("task-x") == 0 + store.add("task-x", {"a": 1}) + assert store.count("task-x") == 1 + + def test_clear(self): + store = KarmaEvidenceStore() + store.add("task-1", {"step": 1}) + store.clear("task-1") + assert store.count("task-1") == 0 + assert store.get_all("task-1") == [] + + def test_independent_tasks(self): + store = KarmaEvidenceStore() + store.add("task-a", {"n": 1}) + store.add("task-b", {"n": 2}) + assert store.count("task-a") == 1 + assert store.count("task-b") == 1 + + +# --------------------------------------------------------------------------- +# KarmaReceiptSigner +# --------------------------------------------------------------------------- + + +class TestKarmaReceiptSigner: + def test_no_signer_returns_empty(self): + signer = KarmaReceiptSigner(agent_address="0x1234") + assert signer.sign_digest(b"\x00" * 32) == "0x" + + def test_signer_with_function(self): + def fake_sign(digest: bytes) -> bytes: + return b"\x01" * 65 + + signer = KarmaReceiptSigner(agent_address="0xabcd", _sign_fn=fake_sign) + sig = signer.sign_digest(b"\xab" * 32) + assert sig.startswith("0x") + assert len(sig) == 132 # 2 + 130 hex chars for 65 bytes + + def test_signer_exception_fallback(self): + def bad_sign(_): + raise RuntimeError("HSM offline") + + signer = KarmaReceiptSigner(agent_address="0xdead", _sign_fn=bad_sign) + assert signer.sign_digest(b"\x00" * 32) == "0x" + + +# --------------------------------------------------------------------------- +# KarmaEvaluator +# --------------------------------------------------------------------------- + + +class TestKarmaEvaluator: + """Off-chain evaluator tests (no live network dependency).""" + + def test_init_defaults(self): + ev = KarmaEvaluator(runtime_url="https://karma.example.com") + assert ev.runtime_url == "https://karma.example.com" + assert ev.api_key == "" + assert ev.strict is True + assert ev.timeout == 120.0 + + def test_init_with_api_key(self): + ev = KarmaEvaluator( + runtime_url="https://karma.example.com", + api_key="karma_secret_123", + strict=False, + timeout=60.0, + ) + assert ev.api_key == "karma_secret_123" + assert ev.strict is False + assert ev.timeout == 60.0 + + def test_compute_bundle_hash(self): + h = KarmaEvaluator._compute_bundle_hash( + evidence_bundle={"task_id": "t1", "hash": "abc"}, + receipts=[{"step": 1}, {"step": 2}], + ) + assert h.startswith("0x") + assert len(h) == 66 # 0x + 64 hex chars + + def test_compute_bundle_hash_empty(self): + assert KarmaEvaluator._compute_bundle_hash(None, None) == "0x" + assert KarmaEvaluator._compute_bundle_hash({}, []) == "0x" + + def test_reject_method(self): + result = KarmaEvaluator._reject("bad stuff", "0xdeadbeef") + assert result["verdict"] == "REJECT" + assert result["score"] == 0.0 + assert result["reason"] == "bad stuff" + assert result["bundle_hash"] == "0xdeadbeef" + + def test_pending_method(self): + result = KarmaEvaluator._pending("waiting...", "0xbeef") + assert result["verdict"] == "PENDING" + assert result["score"] == 0.0 + assert result["reason"] == "waiting..." + + def test_interpret_approve(self): + ev = KarmaEvaluator(runtime_url="https://k.test") + karma_result = { + "passed": True, + "score": 0.98, + "verification_id": "vfy-123", + "receipt_count": 5, + "reason": "All receipts verified", + } + result = ev._interpret(karma_result, "0xhash", {}) + assert result["verdict"] == "APPROVE" + assert result["score"] == 0.98 + assert result["verification_id"] == "vfy-123" + assert result["receipt_count"] == 5 + + def test_interpret_reject(self): + ev = KarmaEvaluator(runtime_url="https://k.test") + karma_result = { + "verified": False, + "confidence": 0.12, + "id": "vfy-456", + "message": "Input hash mismatch at step 3", + } + result = ev._interpret(karma_result, "0xcafe", {}) + assert result["verdict"] == "REJECT" + assert result["score"] == 0.12 + + def test_build_payload_minimal(self): + ev = KarmaEvaluator(runtime_url="https://k.test") + payload = ev._build_payload("task-abc", None, None, None) + assert payload == {"task_id": "task-abc"} + + def test_build_payload_full(self): + ev = KarmaEvaluator(runtime_url="https://k.test") + payload = ev._build_payload( + "task-xyz", + evidence_bundle={"bundle_id": "b1"}, + receipts=[{"step": 0}], + contract={"client": "alice", "budget": 100}, + ) + assert payload["task_id"] == "task-xyz" + assert payload["bundle"] == {"bundle_id": "b1"} + assert payload["receipts"] == [{"step": 0}] + assert payload["contract"] == {"client": "alice", "budget": 100} + + +# --------------------------------------------------------------------------- +# Evidence encoding +# --------------------------------------------------------------------------- + + +class TestEvidenceEncoding: + def test_build_karma_evidence_bytes(self): + evidence = build_karma_evidence_bytes( + verification_id="v-001", + verdict="APPROVE", + score=0.99, + receipt_count=3, + bundle_hash="0xabcdef", + ) + assert isinstance(evidence, bytes) + data = json.loads(evidence) + assert data["karma"]["verification_id"] == "v-001" + assert data["karma"]["verdict"] == "APPROVE" + assert data["karma"]["score"] == 0.99 + assert data["karma"]["receipt_count"] == 3 + assert data["karma"]["bundle_hash"] == "0xabcdef" + assert "verified_at" in data["karma"] + + def test_parse_karma_evidence_valid(self): + evidence = build_karma_evidence_bytes("v-002", "REJECT", 0.1, 0, "0xdead") + parsed = parse_karma_evidence(evidence) + assert parsed is not None + assert parsed["verdict"] == "REJECT" + + def test_parse_karma_evidence_no_karma_field(self): + record = json.dumps({"other": "data"}).encode() + assert parse_karma_evidence(record) is None + + def test_parse_karma_evidence_invalid_json(self): + assert parse_karma_evidence(b"not json at all") is None + assert parse_karma_evidence(b"") is None + + +class TestVerifyReceiptDigest: + def test_match(self): + receipt = {"step": 1, "output": "hello"} + from web3 import Web3 + + canonical = json.dumps(receipt, sort_keys=True, separators=(",", ":")) + expected = Web3.keccak(text=canonical).hex() + assert verify_karma_receipt_digest(receipt, expected) is True + + def test_mismatch(self): + assert verify_karma_receipt_digest({"x": 1}, "0x" + "00" * 32) is False + + +# --------------------------------------------------------------------------- +# KarmaBNBVerifier evidence encoding (no live chain) +# --------------------------------------------------------------------------- + + +class TestKarmaBNBVerifierEvidence: + def test_encode_evidence(self): + eval_result = { + "verdict": "APPROVE", + "verification_id": "vfy-999", + "score": 0.95, + "receipt_count": 7, + "bundle_hash": "0xhash123", + } + evidence = KarmaBNBVerifier._encode_evidence(eval_result) + data = json.loads(evidence) + assert data["karma"]["verdict"] == "APPROVE" + assert data["karma"]["receipt_count"] == 7 + assert "verified_at" in data["karma"]