aimarket-oracle-core 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,36 @@
1
+ # Python
2
+ .venv/
3
+ **/.venv/
4
+ **/__pycache__/
5
+ **/.pytest_cache/
6
+ .coverage
7
+ coverage.json
8
+ **/coverage.json
9
+ *.pyc
10
+ *.egg-info/
11
+ dist/
12
+ build/
13
+
14
+ # Node / frontend
15
+ **/node_modules/
16
+ **/frontend/dist/
17
+ **/test-results/
18
+ **/playwright-report/
19
+
20
+ # Secrets & runtime data (any location)
21
+ data/
22
+ **/data/
23
+ **/*_signing_key
24
+ **/*_signing_key_mldsa
25
+ **/*_mldsa
26
+ **/test_key
27
+ .env
28
+ .env.*
29
+ !.env.example
30
+
31
+ # OS / IDE
32
+ .DS_Store
33
+ *.swp
34
+ *.log
35
+ .idea/
36
+ .vscode/
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.4
2
+ Name: aimarket-oracle-core
3
+ Version: 0.2.0
4
+ Summary: Shared AIMarket v2 infrastructure for the oracle family
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.11
7
+ Requires-Dist: cryptography>=44.0.0
8
+ Requires-Dist: fastapi>=0.115.0
9
+ Requires-Dist: httpx>=0.27.0
10
+ Requires-Dist: pydantic>=2.9.0
11
+ Requires-Dist: uvicorn[standard]>=0.32.0
12
+ Provides-Extra: dev
13
+ Requires-Dist: httpx>=0.27.0; extra == 'dev'
14
+ Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
15
+ Requires-Dist: pytest>=8.3.0; extra == 'dev'
16
+ Provides-Extra: pqc
17
+ Requires-Dist: dilithium-py>=1.4.0; extra == 'pqc'
18
+ Description-Content-Type: text/markdown
19
+
20
+ # aimarket-oracle-core
21
+
22
+ Shared infrastructure for the [alexar76 oracle family](https://github.com/alexar76/oracles) —
23
+ the layer that turns a pure function into a sellable, verifiable capability on
24
+ **AIMarket Protocol v2**.
25
+
26
+ An oracle written on top of this declares its capabilities and its maths. Everything else —
27
+ the HTTP surface, the signed manifest, priced invoke, Ed25519 receipts, measured latency and
28
+ success metrics, rate limiting, and hub federation — comes from here.
29
+
30
+ ```python
31
+ from oracle_core import Capability, OracleSpec
32
+
33
+ SPEC = OracleSpec(
34
+ name="Example Oracle",
35
+ product_id="prod-example",
36
+ description="What it sells, in a sentence an agent can choose on.",
37
+ capabilities=[
38
+ Capability(
39
+ capability_id="example.double@v1",
40
+ product_id="prod-example",
41
+ description="Return twice the input.",
42
+ handler=lambda d: {"result": 2 * float(d["value"])},
43
+ input_schema={"type": "object", "required": ["value"],
44
+ "properties": {"value": {"type": "number"}}},
45
+ output_schema={"type": "object", "required": ["result"],
46
+ "properties": {"result": {"type": "number"}}},
47
+ price_per_call_usd=0.001,
48
+ ),
49
+ ],
50
+ )
51
+ ```
52
+
53
+ ## Install
54
+
55
+ ```bash
56
+ pip install aimarket-oracle-core
57
+ ```
58
+
59
+ The distribution is `aimarket-oracle-core`; the import is `oracle_core`. The unprefixed
60
+ name `oracle-core` on PyPI is an **unrelated project** that installs a module of the same
61
+ name — installing it in place of this one fails at
62
+ `ImportError: cannot import name 'Capability' from 'oracle_core'`.
63
+
64
+ ## What you get
65
+
66
+ | | |
67
+ |---|---|
68
+ | **Protocol** | `.well-known/ai-market.json` discovery, v2 manifest, priced `invoke` |
69
+ | **Signing** | Ed25519 manifest signatures and 7-field receipts, canonical form shared with the hub; optional hybrid post-quantum via the `pqc` extra |
70
+ | **Metrics** | measured `p50_latency_ms` and `success_rate_30d`, not declared constants |
71
+ | **Safety** | handler exceptions become named refusals (`{"ok": false, "error": …}`) rather than opaque 500s |
72
+ | **Federation** | manifests a hub can verify byte-for-byte and re-list |
73
+
74
+ ## Extras
75
+
76
+ - `pqc` — hybrid post-quantum signatures (`dilithium-py`)
77
+ - `dev` — pytest, pytest-asyncio, httpx
78
+
79
+ Apache-2.0.
@@ -0,0 +1,60 @@
1
+ # aimarket-oracle-core
2
+
3
+ Shared infrastructure for the [alexar76 oracle family](https://github.com/alexar76/oracles) —
4
+ the layer that turns a pure function into a sellable, verifiable capability on
5
+ **AIMarket Protocol v2**.
6
+
7
+ An oracle written on top of this declares its capabilities and its maths. Everything else —
8
+ the HTTP surface, the signed manifest, priced invoke, Ed25519 receipts, measured latency and
9
+ success metrics, rate limiting, and hub federation — comes from here.
10
+
11
+ ```python
12
+ from oracle_core import Capability, OracleSpec
13
+
14
+ SPEC = OracleSpec(
15
+ name="Example Oracle",
16
+ product_id="prod-example",
17
+ description="What it sells, in a sentence an agent can choose on.",
18
+ capabilities=[
19
+ Capability(
20
+ capability_id="example.double@v1",
21
+ product_id="prod-example",
22
+ description="Return twice the input.",
23
+ handler=lambda d: {"result": 2 * float(d["value"])},
24
+ input_schema={"type": "object", "required": ["value"],
25
+ "properties": {"value": {"type": "number"}}},
26
+ output_schema={"type": "object", "required": ["result"],
27
+ "properties": {"result": {"type": "number"}}},
28
+ price_per_call_usd=0.001,
29
+ ),
30
+ ],
31
+ )
32
+ ```
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ pip install aimarket-oracle-core
38
+ ```
39
+
40
+ The distribution is `aimarket-oracle-core`; the import is `oracle_core`. The unprefixed
41
+ name `oracle-core` on PyPI is an **unrelated project** that installs a module of the same
42
+ name — installing it in place of this one fails at
43
+ `ImportError: cannot import name 'Capability' from 'oracle_core'`.
44
+
45
+ ## What you get
46
+
47
+ | | |
48
+ |---|---|
49
+ | **Protocol** | `.well-known/ai-market.json` discovery, v2 manifest, priced `invoke` |
50
+ | **Signing** | Ed25519 manifest signatures and 7-field receipts, canonical form shared with the hub; optional hybrid post-quantum via the `pqc` extra |
51
+ | **Metrics** | measured `p50_latency_ms` and `success_rate_30d`, not declared constants |
52
+ | **Safety** | handler exceptions become named refusals (`{"ok": false, "error": …}`) rather than opaque 500s |
53
+ | **Federation** | manifests a hub can verify byte-for-byte and re-list |
54
+
55
+ ## Extras
56
+
57
+ - `pqc` — hybrid post-quantum signatures (`dilithium-py`)
58
+ - `dev` — pytest, pytest-asyncio, httpx
59
+
60
+ Apache-2.0.
@@ -0,0 +1,100 @@
1
+ # oracle-core signing — implementation note
2
+
3
+ This document describes **what the code does today**. It is **not** a normative protocol spec,
4
+ an external audit, or a proof of correctness. For ecosystem-level honesty see
5
+ [`docs/crypto-maturity.en.md`](../../docs/crypto-maturity.en.md).
6
+
7
+ Implementation: [`oracle_core/signing.py`](../oracle_core/signing.py).
8
+
9
+ ---
10
+
11
+ ## Ed25519 (default, always on)
12
+
13
+ **Manifest canonical string** (4 fields + tools hash):
14
+
15
+ ```
16
+ capabilities_count:{n}|generated_at:{iso}|protocol_version:{v}|tools_hash:{sha256}
17
+ ```
18
+
19
+ `tools_hash = SHA-256(JSON.dumps(tools, sort_keys=True))`.
20
+
21
+ **Receipt canonical string** (7 fields):
22
+
23
+ ```
24
+ nonce:{n}|product_id:{id}|capability_id:{cap}|price_usd:{p}|timestamp:{iso}|success:{0|1}|latency_ms:{ms}
25
+ ```
26
+
27
+ **Signature object** on manifests (via `sign_payload`):
28
+
29
+ ```json
30
+ {
31
+ "algorithm": "ed25519",
32
+ "public_key": "<base64 32-byte pubkey>",
33
+ "value": "<base64 64-byte sig>"
34
+ }
35
+ ```
36
+
37
+ Receipts embed `signature.algorithm` + `signature.value` only (legacy 7-field receipt shape).
38
+
39
+ Verification uses `cryptography` Ed25519 over the UTF-8 canonical string.
40
+
41
+ ---
42
+
43
+ ## Hybrid ML-DSA-65 (optional, off by default)
44
+
45
+ Enable: `ORACLE_PQC=1` or `Signer(..., pqc=True)` **and** install `dilithium-py`.
46
+
47
+ When enabled, `sign_payload` **adds** (does not replace Ed25519):
48
+
49
+ ```json
50
+ {
51
+ "pq_algorithm": "ml-dsa-65",
52
+ "pq_public_key": "<base64 ML-DSA-65 public key>",
53
+ "pq_value": "<base64 ML-DSA-65 signature>"
54
+ }
55
+ ```
56
+
57
+ **Verification policy** (`verify_signature_object`):
58
+
59
+ 1. Ed25519 **must** verify.
60
+ 2. If `pq_value` is present, ML-DSA-65 **must also** verify (both required).
61
+ 3. If `pq_value` is absent, Ed25519 alone suffices.
62
+
63
+ **Rationale (informal):** dual signatures — safe if either primitive holds during migration.
64
+ This is a common hybrid pattern but **has not been independently reviewed** for this codebase.
65
+
66
+ ---
67
+
68
+ ## Key material
69
+
70
+ | Key | Storage | Notes |
71
+ |-----|---------|-------|
72
+ | Ed25519 | `{key_path}` — 64 B seed‖pub | Or `ORACLE_SIGNING_SEED_B64` env (32 B seed) |
73
+ | ML-DSA-65 | `{key_path}_mldsa` — hex pk/sk lines | Generated on first use when PQC enabled |
74
+
75
+ File permissions attempted `0600`. **No HSM integration** in-tree.
76
+
77
+ ---
78
+
79
+ ## Hub integration gap
80
+
81
+ The AIMarket Hub verifies **Ed25519 manifest/receipt signatures** on its hot path. PQ fields are
82
+ **ignored** unless a consumer calls `Signer.verify_signature_object` locally.
83
+
84
+ Until protocol v2.x freezes PQ fields and the Hub enforces both layers, treat hybrid PQC as
85
+ **operator opt-in**, not ecosystem-wide post-quantum readiness.
86
+
87
+ ---
88
+
89
+ ## Test coverage
90
+
91
+ - `core/tests/test_core.py` — Ed25519 round-trip; hybrid when `dilithium-py` installed.
92
+ - `oracles/platon/backend/tests/test_signing.py` — hybrid tamper cases.
93
+
94
+ **Missing for production hardening:**
95
+
96
+ - Negative test vectors published in `aimarket-protocol`
97
+ - Cross-language verifiers (TypeScript, Solidity) for PQ extension
98
+ - External audit of canonical string choices and key-binding policy
99
+
100
+ See [KI-6](https://github.com/alexar76/aicom/blob/main/docs/known-issues.md#ki-6--oracle-family-cryptographic-maturity-not-production-hardened).
@@ -0,0 +1,37 @@
1
+ """oracle-core — shared AIMarket v2 infrastructure for the oracle family.
2
+
3
+ Build an oracle by declaring capabilities and handing them to ``create_app``:
4
+
5
+ from oracle_core import Capability, OracleSpec, create_app
6
+
7
+ spec = OracleSpec(
8
+ name="My Oracle", product_id="prod-x", description="...",
9
+ public_url="http://localhost:9300", categories=["..."],
10
+ capabilities=[Capability("x.do@v1", "does x", handler=lambda d: {...})],
11
+ )
12
+ app = create_app(spec)
13
+
14
+ You get signed manifest + invoke (with receipts + measured metrics) + .well-known
15
+ + rate-limiting + (optional) hybrid PQC for free.
16
+ """
17
+
18
+ from oracle_core.app import create_app
19
+ from oracle_core.hub_client import HubClient
20
+ from oracle_core.metrics import Metrics
21
+ from oracle_core.protocol import Capability, OracleSpec, Protocol, input_hash, utc_now_z
22
+ from oracle_core.ratelimit import RateLimiter
23
+ from oracle_core.signing import Signer, pqc_available
24
+
25
+ __all__ = [
26
+ "Capability",
27
+ "OracleSpec",
28
+ "Protocol",
29
+ "create_app",
30
+ "HubClient",
31
+ "Metrics",
32
+ "RateLimiter",
33
+ "Signer",
34
+ "pqc_available",
35
+ "input_hash",
36
+ "utc_now_z",
37
+ ]
@@ -0,0 +1,108 @@
1
+ """FastAPI app factory — every oracle gets a compliant AIMarket v2 surface for free."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any, Callable, Optional
7
+
8
+ from fastapi import FastAPI, HTTPException, Request
9
+ from fastapi.middleware.cors import CORSMiddleware
10
+ from pydantic import BaseModel, Field
11
+
12
+ from oracle_core.protocol import OracleSpec, Protocol
13
+ from oracle_core.ratelimit import RateLimiter
14
+
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class InvokeRequest(BaseModel):
20
+ capability_id: str
21
+ input: dict[str, Any] = Field(default_factory=dict)
22
+
23
+
24
+ def client_key(request: Request) -> str:
25
+ """Per-client rate-limit key — the real client IP behind the reverse proxy.
26
+
27
+ Behind nginx the socket peer is always 127.0.0.1, so trust the proxy-set
28
+ ``X-Real-IP`` / first ``X-Forwarded-For`` hop. This is only spoofable by a
29
+ client that can reach the app directly; the service binds to loopback and is
30
+ published solely through nginx, so that path is closed.
31
+ """
32
+ xri = (request.headers.get("x-real-ip") or "").strip()
33
+ if xri:
34
+ return xri
35
+ xff = (request.headers.get("x-forwarded-for") or "").strip()
36
+ if xff:
37
+ return xff.split(",")[0].strip()
38
+ return request.client.host if request.client else "*"
39
+
40
+
41
+ def create_app(
42
+ spec: OracleSpec,
43
+ cors_origins: str = "*",
44
+ invoke_rate_limit: int = 120,
45
+ extra: Optional[Callable[[FastAPI, Protocol], None]] = None,
46
+ ) -> FastAPI:
47
+ proto = Protocol(spec)
48
+ limiter = RateLimiter(invoke_rate_limit)
49
+
50
+ app = FastAPI(title=spec.name, description=spec.description, version=spec.version)
51
+ app.state.protocol = proto # exposed for tests / extra routes
52
+
53
+ origins = [o.strip() for o in cors_origins.split(",") if o.strip()] or ["*"]
54
+ allow_all = origins == ["*"]
55
+ app.add_middleware(
56
+ CORSMiddleware,
57
+ allow_origins=origins,
58
+ allow_credentials=not allow_all, # "*" + credentials is invalid per the CORS spec
59
+ allow_methods=["*"],
60
+ allow_headers=["*"],
61
+ )
62
+
63
+ @app.get("/api/health")
64
+ async def health() -> dict[str, Any]:
65
+ return {"status": "ok", "oracle": spec.product_id, "capabilities": len(spec.capabilities)}
66
+
67
+ @app.get("/.well-known/ai-market.json")
68
+ async def well_known() -> dict[str, Any]:
69
+ return proto.well_known()
70
+
71
+ @app.get("/ai-market/v2/manifest")
72
+ async def manifest() -> dict[str, Any]:
73
+ return proto.manifest()
74
+
75
+ @app.post("/ai-market/v2/invoke")
76
+ async def invoke(req: InvokeRequest, request: Request) -> dict[str, Any]:
77
+ if not limiter.allow(client_key(request)):
78
+ raise HTTPException(status_code=429, detail="rate limited")
79
+ try:
80
+ result = await proto.invoke(req.capability_id, req.input)
81
+ return {"ok": True, **result}
82
+ except ValueError as exc:
83
+ # A handler rejecting its input, e.g. "points must be a list of [x, y] pairs".
84
+ return {"ok": False, "error": str(exc)}
85
+ except KeyError as exc:
86
+ # A required field was simply absent. Only ValueError used to be translated, so
87
+ # `lumen.score` without `target_node` answered a bare 500 Internal Server Error
88
+ # — a caller that read the schema and mistyped one field learned nothing and had
89
+ # nothing to correct. KeyError stringifies to a quoted name, hence the strip.
90
+ return {"ok": False, "error": f"missing required input field: {str(exc).strip(chr(39))}"}
91
+ except RuntimeError as exc:
92
+ # A federated upstream refused. The oracle-family proxy raises this carrying the
93
+ # upstream's own message ("Unknown capability: …"), which is exactly what the
94
+ # caller needs and exactly what the 500 was swallowing.
95
+ return {"ok": False, "error": str(exc)}
96
+ except Exception as exc: # noqa: BLE001 — deliberate catch-all, see below
97
+ # Anything else is a real fault, not a refusal, so it stays a 5xx. But it says
98
+ # what broke: an empty "Internal Server Error" is indistinguishable from a dead
99
+ # process, and callers retry it forever. Full traceback goes to the log.
100
+ logger.exception("invoke %s failed unexpectedly", req.capability_id)
101
+ raise HTTPException(
102
+ status_code=500, detail=f"{type(exc).__name__}: {exc}"
103
+ ) from exc
104
+
105
+ if extra is not None:
106
+ extra(app, proto)
107
+
108
+ return app
@@ -0,0 +1,89 @@
1
+ """Generic AIMarket hub federation client (shared by every oracle)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Optional
6
+
7
+ import httpx
8
+
9
+ from oracle_core.protocol import Protocol
10
+
11
+
12
+ def announce_canonical(hub_url: str, well_known_url: str, capabilities_count: int) -> str:
13
+ return f"hub_url:{hub_url}|well_known_url:{well_known_url}|capabilities_count:{capabilities_count}"
14
+
15
+
16
+ class HubClient:
17
+ def __init__(self, proto: Protocol, hub_url: str):
18
+ self.proto = proto
19
+ self.hub_url = hub_url.rstrip("/")
20
+
21
+ def self_verify_manifest(self) -> dict[str, Any]:
22
+ man = self.proto.manifest()
23
+ return {
24
+ "ok": self.proto.signer.verify_manifest_signature(man),
25
+ "capabilities": [t["capability_id"] for t in man["tools"]],
26
+ "signer_public_key": self.proto.signer.public_key_b64,
27
+ }
28
+
29
+ def announce_body(self) -> dict[str, Any]:
30
+ base = self.proto.spec.public_url.rstrip("/")
31
+ wk = f"{base}/.well-known/ai-market.json"
32
+ count = len(self.proto.spec.capabilities)
33
+ canonical = announce_canonical(base, wk, count)
34
+ return {
35
+ "hub_url": base,
36
+ "well_known_url": wk,
37
+ "capabilities_count": count,
38
+ "hub_name": self.proto.spec.name,
39
+ "signer_public_key": self.proto.signer.public_key_b64,
40
+ "signature": {"algorithm": "ed25519", "value": self.proto.signer.sign_canonical(canonical)},
41
+ }
42
+
43
+ async def hub_info(self) -> dict[str, Any]:
44
+ try:
45
+ async with httpx.AsyncClient(timeout=15.0) as c:
46
+ r = await c.get(f"{self.hub_url}/.well-known/ai-market.json")
47
+ if r.status_code == 200:
48
+ d = r.json()
49
+ return {"reachable": True, "name": d.get("name"), "hub_version": d.get("hub_version")}
50
+ return {"reachable": False, "status": r.status_code}
51
+ except httpx.HTTPError as exc:
52
+ return {"reachable": False, "error": str(exc)}
53
+
54
+ async def announce(self, admin_token: str = "") -> dict[str, Any]:
55
+ params = {"authorization": admin_token} if admin_token else {}
56
+ try:
57
+ async with httpx.AsyncClient(timeout=20.0) as c:
58
+ r = await c.post(f"{self.hub_url}/ai-market/v2/federation/announce", params=params, json=self.announce_body())
59
+ body: Any
60
+ try:
61
+ body = r.json()
62
+ except ValueError:
63
+ body = r.text
64
+ return {"registered": r.status_code == 200, "status": r.status_code, "response": body}
65
+ except httpx.HTTPError as exc:
66
+ return {"registered": False, "error": str(exc)}
67
+
68
+ async def open_channel(self, deposit_usd: float = 1.0, token: str = "USDT", chain: str = "base") -> dict[str, Any]:
69
+ try:
70
+ async with httpx.AsyncClient(timeout=20.0) as c:
71
+ r = await c.post(f"{self.hub_url}/ai-market/v2/channel/open", json={"deposit_usd": deposit_usd, "token": token, "chain": chain})
72
+ return {"status": r.status_code, "response": _json_or_text(r)}
73
+ except httpx.HTTPError as exc:
74
+ return {"error": str(exc)}
75
+
76
+ async def search(self, intent: str, budget: float = 0.05, limit: int = 10) -> dict[str, Any]:
77
+ try:
78
+ async with httpx.AsyncClient(timeout=20.0) as c:
79
+ r = await c.get(f"{self.hub_url}/ai-market/v2/search", params={"intent": intent, "budget": budget, "limit": limit})
80
+ return {"status": r.status_code, "response": _json_or_text(r)}
81
+ except httpx.HTTPError as exc:
82
+ return {"error": str(exc)}
83
+
84
+
85
+ def _json_or_text(r: "httpx.Response") -> Any:
86
+ try:
87
+ return r.json()
88
+ except ValueError:
89
+ return r.text
@@ -0,0 +1,37 @@
1
+ """Rolling per-capability metrics — real measured latency and success rate."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict, deque
6
+ from typing import Optional
7
+
8
+
9
+ class Metrics:
10
+ def __init__(self, window: int = 200) -> None:
11
+ self._window = window
12
+ self._latency: dict[str, deque] = defaultdict(lambda: deque(maxlen=window))
13
+ self._success: dict[str, deque] = defaultdict(lambda: deque(maxlen=window))
14
+
15
+ def record(self, capability_id: str, latency_ms: float, success: bool) -> None:
16
+ self._latency[capability_id].append(float(latency_ms))
17
+ self._success[capability_id].append(1 if success else 0)
18
+
19
+ def count(self, capability_id: str) -> int:
20
+ return len(self._success.get(capability_id, ()))
21
+
22
+ def p50_latency_ms(self, capability_id: str) -> Optional[float]:
23
+ samples = self._latency.get(capability_id)
24
+ if not samples:
25
+ return None
26
+ ordered = sorted(samples)
27
+ return ordered[len(ordered) // 2]
28
+
29
+ def success_rate(self, capability_id: str) -> Optional[float]:
30
+ samples = self._success.get(capability_id)
31
+ if not samples:
32
+ return None
33
+ return sum(samples) / len(samples)
34
+
35
+ def reset(self) -> None:
36
+ self._latency.clear()
37
+ self._success.clear()
@@ -0,0 +1,193 @@
1
+ """AIMarket Protocol v2 — shared by every oracle in the family.
2
+
3
+ An oracle declares an :class:`OracleSpec` (name, product, priced capabilities with
4
+ handlers); this module turns it into a compliant ``.well-known`` doc, a signed
5
+ manifest (measured metrics overlaid), and an ``invoke`` that wraps every result in
6
+ a signed 7-field receipt + provenance. No per-oracle protocol code needed.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import hashlib
13
+ import inspect
14
+ import json
15
+ import secrets
16
+ import time
17
+ from dataclasses import dataclass, field
18
+ from datetime import datetime, timezone
19
+ from typing import Any, Callable
20
+
21
+ from oracle_core.metrics import Metrics
22
+ from oracle_core.signing import Signer
23
+
24
+
25
+ def utc_now_z() -> str:
26
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
27
+
28
+
29
+ def input_hash(input_data: dict[str, Any]) -> str:
30
+ return hashlib.sha256(
31
+ json.dumps(input_data, sort_keys=True, ensure_ascii=False).encode()
32
+ ).hexdigest()
33
+
34
+
35
+ @dataclass
36
+ class Capability:
37
+ capability_id: str # e.g. "platon.random@v1"
38
+ description: str
39
+ handler: Callable[[dict[str, Any]], Any] # sync or async; receives the input dict
40
+ product_id: str = "prod-oracle"
41
+ input_schema: dict[str, Any] = field(default_factory=lambda: {"type": "object", "properties": {}})
42
+ output_schema: dict[str, Any] = field(default_factory=lambda: {"type": "object"})
43
+ price_per_call_usd: float = 0.001
44
+ p50_latency_ms: float = 10
45
+ success_rate_30d: float = 0.999
46
+
47
+ @property
48
+ def name(self) -> str:
49
+ return self.capability_id.split("@", 1)[0]
50
+
51
+ def tool(self) -> dict[str, Any]:
52
+ return {
53
+ "name": self.name,
54
+ "capability_id": self.capability_id,
55
+ "product_id": self.product_id,
56
+ "description": self.description,
57
+ "input_schema": self.input_schema,
58
+ "output_schema": self.output_schema,
59
+ "price_per_call_usd": self.price_per_call_usd,
60
+ # Coerced for the same reason as the measured value in _tool_with_metrics: the
61
+ # field is declared `integer`, and a spec author writing 10.5 would silently
62
+ # make this oracle unfederatable.
63
+ "p50_latency_ms": int(round(self.p50_latency_ms)),
64
+ "success_rate_30d": self.success_rate_30d,
65
+ }
66
+
67
+
68
+ @dataclass
69
+ class OracleSpec:
70
+ name: str
71
+ product_id: str
72
+ description: str
73
+ public_url: str
74
+ categories: list[str]
75
+ capabilities: list[Capability]
76
+ signing_key_path: str = "data/signing_key"
77
+ version: str = "0.1.0"
78
+ related: list[str] = field(default_factory=list)
79
+
80
+ def capability(self, capability_id: str) -> Capability:
81
+ for c in self.capabilities:
82
+ if c.capability_id == capability_id:
83
+ return c
84
+ raise ValueError(f"Unknown capability: {capability_id}")
85
+
86
+
87
+ class Protocol:
88
+ """Binds an OracleSpec to a Signer + Metrics and serves the v2 surface."""
89
+
90
+ def __init__(self, spec: OracleSpec, signer: Signer | None = None, metrics: Metrics | None = None):
91
+ self.spec = spec
92
+ self.signer = signer or Signer(spec.signing_key_path)
93
+ self.metrics = metrics or Metrics()
94
+
95
+ def well_known(self) -> dict[str, Any]:
96
+ base = self.spec.public_url.rstrip("/")
97
+ return {
98
+ "name": self.spec.name,
99
+ "protocol_versions": ["v2"],
100
+ "hub_version": self.spec.version,
101
+ "manifest_url": f"{base}/ai-market/v2/manifest",
102
+ "mcp_endpoint": f"{base}/ai-market/v2/invoke",
103
+ "capabilities_count": len(self.spec.capabilities),
104
+ "signer_public_key": self.signer.public_key_b64,
105
+ "description": self.spec.description,
106
+ "categories": self.spec.categories,
107
+ "protocol_version": "v2",
108
+ "hub_name": self.spec.name,
109
+ "hub_url": base,
110
+ "ecosystem": {"product": self.spec.product_id, "related": self.spec.related},
111
+ }
112
+
113
+ def _tool_with_metrics(self, cap: Capability) -> dict[str, Any]:
114
+ tool = cap.tool()
115
+ cid = cap.capability_id
116
+ observed = self.metrics.count(cid)
117
+ p50 = self.metrics.p50_latency_ms(cid)
118
+ sr = self.metrics.success_rate(cid)
119
+ if p50 is not None:
120
+ # int, not round(p50, 2): aimarket-protocol/schemas/manifest.json declares
121
+ # p50_latency_ms as {"type": "integer"}, so a measured 2261.84 made the hub
122
+ # reject the WHOLE manifest — "2261.84 is not of type 'integer'" — and index
123
+ # zero of the family's 22 capabilities. Sub-millisecond precision in a
124
+ # catalogue listing buys nothing; interoperating with the published schema
125
+ # buys federation.
126
+ tool["p50_latency_ms"] = int(round(p50))
127
+ if sr is not None:
128
+ tool["success_rate_30d"] = round(sr, 4)
129
+ tool["calls_observed"] = observed
130
+ tool["metrics_source"] = "measured" if observed else "declared"
131
+ return tool
132
+
133
+ def manifest(self) -> dict[str, Any]:
134
+ tools = [self._tool_with_metrics(c) for c in self.spec.capabilities]
135
+ body = {
136
+ "protocol_version": "v2",
137
+ "release_version": self.spec.version,
138
+ "generated_at": utc_now_z(),
139
+ "base_url": self.spec.public_url,
140
+ "products_count": 1,
141
+ "capabilities_count": len(tools),
142
+ "total_capabilities": len(tools),
143
+ "local_capabilities": len(tools),
144
+ "federated_capabilities": 0,
145
+ "hubs_indexed": 0,
146
+ "tools": tools,
147
+ }
148
+ body["signature"] = self.signer.sign_manifest(body)
149
+ return body
150
+
151
+ async def invoke(self, capability_id: str, input_data: dict[str, Any]) -> dict[str, Any]:
152
+ cap = self.spec.capability(capability_id) # ValueError -> caller maps to {ok:false}
153
+ start = time.perf_counter()
154
+ success = True
155
+ try:
156
+ if inspect.iscoroutinefunction(cap.handler):
157
+ output = await cap.handler(input_data)
158
+ else:
159
+ # Sync handlers can be CPU-bound (e.g. Chronos VDF: T sequential
160
+ # squarings over an RSA-2048 modulus). Running them inline would
161
+ # block the event loop and stall every other request — a trivial
162
+ # DoS. Offload to a worker thread so one expensive call is isolated.
163
+ output = await asyncio.to_thread(cap.handler, input_data)
164
+ if inspect.isawaitable(output):
165
+ output = await output
166
+ except Exception:
167
+ success = False
168
+ raise
169
+ finally:
170
+ latency_ms = (time.perf_counter() - start) * 1000.0
171
+ self.metrics.record(capability_id, latency_ms, success)
172
+ return self._envelope(cap, capability_id, input_data, output, latency_ms)
173
+
174
+ def _envelope(self, cap, capability_id, input_data, output, latency_ms) -> dict[str, Any]:
175
+ timestamp = utc_now_z()
176
+ receipt = self.signer.sign_receipt(
177
+ {
178
+ "nonce": secrets.token_hex(8),
179
+ "product_id": cap.product_id,
180
+ "capability_id": capability_id,
181
+ "price_usd": cap.price_per_call_usd,
182
+ "timestamp": timestamp,
183
+ "success": True,
184
+ "latency_ms": round(latency_ms, 2),
185
+ }
186
+ )
187
+ return {
188
+ "capability_id": capability_id,
189
+ "output": output,
190
+ "price_usd": cap.price_per_call_usd,
191
+ "provenance": {"source": self.spec.product_id, "timestamp": timestamp, "input_hash": input_hash(input_data)},
192
+ "receipt": receipt,
193
+ }
@@ -0,0 +1,41 @@
1
+ """Tiny fixed-window rate limiter — bounds DoS / cost on open endpoints.
2
+
3
+ Keyed per client (typically the real client IP supplied by the reverse proxy)
4
+ so a single noisy client cannot exhaust the budget for everyone. An unkeyed
5
+ ``allow()`` falls back to a shared bucket for backwards compatibility.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from collections import defaultdict, deque
12
+
13
+
14
+ class RateLimiter:
15
+ def __init__(self, limit: int, window_s: float = 60.0, *, max_keys: int = 8192) -> None:
16
+ self.limit = limit
17
+ self.window = window_s
18
+ self.max_keys = max_keys
19
+ self._buckets: dict[str, deque[float]] = defaultdict(deque)
20
+
21
+ def allow(self, key: str = "*") -> bool:
22
+ now = time.monotonic()
23
+ # Opportunistic eviction so idle/one-shot keys (e.g. spoofed source IPs)
24
+ # cannot grow the bucket map without bound.
25
+ if len(self._buckets) > self.max_keys:
26
+ self._evict_stale(now)
27
+ hits = self._buckets[key]
28
+ while hits and now - hits[0] > self.window:
29
+ hits.popleft()
30
+ if len(hits) >= self.limit:
31
+ return False
32
+ hits.append(now)
33
+ return True
34
+
35
+ def _evict_stale(self, now: float) -> None:
36
+ stale = [k for k, h in self._buckets.items() if not h or now - h[-1] > self.window]
37
+ for k in stale:
38
+ del self._buckets[k]
39
+
40
+ def reset(self) -> None:
41
+ self._buckets.clear()
@@ -0,0 +1,208 @@
1
+ """Ed25519 signing (+ optional hybrid ML-DSA-65) for the oracle family.
2
+
3
+ Canonical forms for manifest (4-field, with tools_hash) and receipts (7-field)
4
+ match the AIMarket hub exactly, so signatures verify against the live hub. PQC is
5
+ additive: when enabled, an ML-DSA-65 (FIPS 204) signature is attached alongside
6
+ Ed25519 — verifiers that understand it require both; the hub keeps checking Ed25519.
7
+
8
+ Decoupled from any single oracle's config: the seed may come from
9
+ ``ORACLE_SIGNING_SEED_B64`` (secrets manager / KMS) and PQC from ``ORACLE_PQC=1``
10
+ or the constructor flag.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import base64
16
+ import contextlib
17
+ import hashlib
18
+ import json
19
+ import os
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ try:
24
+ from dilithium_py.ml_dsa import ML_DSA_65 as _MLDSA
25
+
26
+ _PQ_LIB = True
27
+ except Exception: # pragma: no cover
28
+ _MLDSA = None
29
+ _PQ_LIB = False
30
+
31
+
32
+ def pqc_available() -> bool:
33
+ return _PQ_LIB
34
+
35
+
36
+ def _ensure_keypair(path: Path) -> tuple[bytes, bytes]:
37
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
38
+
39
+ if path.exists():
40
+ raw = path.read_bytes()
41
+ if len(raw) == 64:
42
+ return raw[:32], raw[32:]
43
+ raise RuntimeError(f"Ed25519 key file {path} is corrupted (size={len(raw)})")
44
+ path.parent.mkdir(parents=True, exist_ok=True)
45
+ priv = Ed25519PrivateKey.generate()
46
+ seed = priv.private_bytes_raw()
47
+ pub = priv.public_key().public_bytes_raw()
48
+ path.write_bytes(seed + pub)
49
+ with contextlib.suppress(OSError):
50
+ os.chmod(path, 0o600)
51
+ return seed, pub
52
+
53
+
54
+ def _seed_from_env() -> bytes | None:
55
+ raw = os.environ.get("ORACLE_SIGNING_SEED_B64")
56
+ if not raw:
57
+ return None
58
+ seed = base64.b64decode(raw)
59
+ if len(seed) != 32:
60
+ raise RuntimeError("ORACLE_SIGNING_SEED_B64 must decode to a 32-byte seed")
61
+ return seed
62
+
63
+
64
+ def _load_or_make_pq(path: Path) -> tuple[bytes, bytes]:
65
+ if path.exists():
66
+ pk_hex, sk_hex = path.read_text().split("\n")[:2]
67
+ return bytes.fromhex(pk_hex), bytes.fromhex(sk_hex)
68
+ pk, sk = _MLDSA.keygen()
69
+ path.parent.mkdir(parents=True, exist_ok=True)
70
+ path.write_text(f"{pk.hex()}\n{sk.hex()}\n")
71
+ with contextlib.suppress(OSError):
72
+ os.chmod(path, 0o600)
73
+ return pk, sk
74
+
75
+
76
+ class Signer:
77
+ def __init__(self, key_path: str | Path = "data/oracle_signing_key", pqc: bool | None = None) -> None:
78
+ self.key_path = Path(key_path)
79
+ env_seed = _seed_from_env()
80
+ if env_seed is not None:
81
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
82
+
83
+ self._seed = env_seed
84
+ self._pub_bytes = (
85
+ Ed25519PrivateKey.from_private_bytes(env_seed).public_key().public_bytes_raw()
86
+ )
87
+ else:
88
+ self._seed, self._pub_bytes = _ensure_keypair(self.key_path)
89
+ self._public_key_b64 = base64.b64encode(self._pub_bytes).decode()
90
+
91
+ if pqc is None:
92
+ pqc = os.environ.get("ORACLE_PQC", "").lower() in ("1", "true", "yes")
93
+ self._pq: tuple[bytes, bytes] | None = None
94
+ if pqc and _PQ_LIB:
95
+ self._pq = _load_or_make_pq(Path(f"{self.key_path}_mldsa"))
96
+
97
+ @property
98
+ def public_key_b64(self) -> str:
99
+ return self._public_key_b64
100
+
101
+ def sign_canonical(self, canonical: str) -> str:
102
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
103
+
104
+ sig = Ed25519PrivateKey.from_private_bytes(self._seed).sign(canonical.encode())
105
+ return base64.b64encode(sig).decode()
106
+
107
+ def sign_payload(self, canonical: str) -> dict[str, str]:
108
+ obj: dict[str, str] = {
109
+ "algorithm": "ed25519",
110
+ "public_key": self._public_key_b64,
111
+ "value": self.sign_canonical(canonical),
112
+ }
113
+ if self._pq is not None:
114
+ pk, sk = self._pq
115
+ obj["pq_algorithm"] = "ml-dsa-65"
116
+ obj["pq_public_key"] = base64.b64encode(pk).decode()
117
+ obj["pq_value"] = base64.b64encode(_MLDSA.sign(sk, canonical.encode())).decode()
118
+ return obj
119
+
120
+ @staticmethod
121
+ def verify(canonical: str, value_b64: str, public_key_b64: str) -> bool:
122
+ from cryptography.exceptions import InvalidSignature
123
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
124
+
125
+ try:
126
+ pub = Ed25519PublicKey.from_public_bytes(base64.b64decode(public_key_b64))
127
+ pub.verify(base64.b64decode(value_b64), canonical.encode())
128
+ return True
129
+ except (InvalidSignature, ValueError):
130
+ return False
131
+
132
+ @staticmethod
133
+ def verify_signature_object(canonical: str, sig: dict[str, Any], ed_public_key_b64: str | None = None) -> bool:
134
+ ed_key = ed_public_key_b64 or sig.get("public_key")
135
+ if not ed_key or not Signer.verify(canonical, sig.get("value", ""), ed_key):
136
+ return False
137
+ if sig.get("pq_value"):
138
+ if not _PQ_LIB:
139
+ return False
140
+ try:
141
+ pk = base64.b64decode(sig.get("pq_public_key", ""))
142
+ return bool(_MLDSA.verify(pk, canonical.encode(), base64.b64decode(sig["pq_value"])))
143
+ except Exception:
144
+ return False
145
+ return True
146
+
147
+ # --- manifest / receipt canonicals (match the AIMarket hub) ---
148
+ def manifest_canonical(self, manifest: dict[str, Any]) -> str:
149
+ """Byte-identical to aimarket_hub.signing.Signer.manifest_canonical.
150
+
151
+ `by_hub_hash` is the fifth field and was missing here. The hub added it so a relay
152
+ cannot tamper with per-peer trust_score and routing metadata under a valid
153
+ signature — and since the hub verifies with ITS canonical, every oracle manifest
154
+ failed with "Invalid manifest signature" and no oracle could federate at all. An
155
+ oracle serves no `by_hub`, so this hashes `{}`, which is exactly what the hub
156
+ computes for the absent key: the two agree without the oracle inventing a field.
157
+
158
+ If the hub's canonical gains a sixth field, this must follow the same day.
159
+ """
160
+ tools = manifest.get("tools", [])
161
+ tools_hash = hashlib.sha256(
162
+ json.dumps(tools, sort_keys=True, ensure_ascii=False).encode()
163
+ ).hexdigest()
164
+ by_hub_hash = hashlib.sha256(
165
+ json.dumps(manifest.get("by_hub", {}), sort_keys=True, ensure_ascii=False).encode()
166
+ ).hexdigest()
167
+ return (
168
+ f"capabilities_count:{manifest.get('capabilities_count', 0)}"
169
+ f"|generated_at:{manifest.get('generated_at', '')}"
170
+ f"|protocol_version:{manifest.get('protocol_version', 'v1')}"
171
+ f"|tools_hash:{tools_hash}"
172
+ f"|by_hub_hash:{by_hub_hash}"
173
+ )
174
+
175
+ def sign_manifest(self, manifest: dict[str, Any]) -> dict[str, str]:
176
+ return self.sign_payload(self.manifest_canonical(manifest))
177
+
178
+ def verify_manifest_signature(self, manifest: dict[str, Any], public_key_b64: str | None = None) -> bool:
179
+ sig = manifest.get("signature") or {}
180
+ key = public_key_b64 or sig.get("public_key") or self._public_key_b64
181
+ return self.verify(self.manifest_canonical(manifest), sig.get("value", ""), key)
182
+
183
+ @staticmethod
184
+ def receipt_canonical(receipt: dict[str, Any]) -> str:
185
+ success = 1 if receipt.get("success", True) else 0
186
+ return (
187
+ f"nonce:{receipt.get('nonce', '')}"
188
+ f"|product_id:{receipt.get('product_id', '')}"
189
+ f"|capability_id:{receipt.get('capability_id', '')}"
190
+ f"|price_usd:{receipt.get('price_usd', 0)}"
191
+ f"|timestamp:{receipt.get('timestamp', '')}"
192
+ f"|success:{success}"
193
+ f"|latency_ms:{receipt.get('latency_ms', 0)}"
194
+ )
195
+
196
+ def sign_receipt(self, receipt: dict[str, Any]) -> dict[str, Any]:
197
+ signed = dict(receipt)
198
+ signed["signature"] = {
199
+ "algorithm": "ed25519",
200
+ "value": self.sign_canonical(self.receipt_canonical(receipt)),
201
+ }
202
+ return signed
203
+
204
+ def verify_receipt(self, receipt: dict[str, Any], public_key_b64: str | None = None) -> bool:
205
+ sig = receipt.get("signature") or {}
206
+ key = public_key_b64 or self._public_key_b64
207
+ body = {k: v for k, v in receipt.items() if k != "signature"}
208
+ return self.verify(self.receipt_canonical(body), sig.get("value", ""), key)
@@ -0,0 +1,39 @@
1
+ [project]
2
+ # `aimarket-oracle-core`, NOT `oracle-core`. The short name belongs to somebody else on
3
+ # PyPI — an unrelated "personal prediction engine" that happens to install the same
4
+ # top-level `oracle_core` module. Every oracle here declared `dependencies =
5
+ # ["oracle-core"]` unpinned, so `pip install oracles/chronos` in a clean environment
6
+ # resolved that name from the index and installed a stranger's package (plus flask,
7
+ # pandas and scikit-learn) instead of this one, ending in
8
+ # `ImportError: cannot import name 'Capability' from 'oracle_core'`. Verified 2026-07-28.
9
+ # The family image survived only because it installs core and all oracles in one pip
10
+ # invocation, where the local project satisfies the requirement before the index is
11
+ # consulted. Do not shorten this name back.
12
+ name = "aimarket-oracle-core"
13
+ version = "0.2.0"
14
+ description = "Shared AIMarket v2 infrastructure for the oracle family"
15
+ readme = "README.md"
16
+ license = "Apache-2.0"
17
+ requires-python = ">=3.11"
18
+ dependencies = [
19
+ "fastapi>=0.115.0",
20
+ "uvicorn[standard]>=0.32.0",
21
+ "httpx>=0.27.0",
22
+ "pydantic>=2.9.0",
23
+ "cryptography>=44.0.0",
24
+ ]
25
+
26
+ [project.optional-dependencies]
27
+ dev = ["pytest>=8.3.0", "pytest-asyncio>=0.24.0", "httpx>=0.27.0"]
28
+ pqc = ["dilithium-py>=1.4.0"]
29
+
30
+ [build-system]
31
+ requires = ["hatchling"]
32
+ build-backend = "hatchling.build"
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ packages = ["oracle_core"]
36
+
37
+ [tool.pytest.ini_options]
38
+ asyncio_mode = "auto"
39
+ testpaths = ["tests"]
@@ -0,0 +1,216 @@
1
+ import pytest
2
+ from httpx import ASGITransport, AsyncClient
3
+
4
+ from oracle_core import Capability, OracleSpec, Signer, create_app
5
+ from oracle_core.protocol import Protocol
6
+
7
+
8
+ def _spec(tmp_path):
9
+ return OracleSpec(
10
+ name="Test Oracle",
11
+ product_id="prod-test",
12
+ description="test",
13
+ public_url="http://localhost:9999",
14
+ categories=["test"],
15
+ signing_key_path=str(tmp_path / "key"),
16
+ capabilities=[
17
+ Capability("test.echo@v1", "echo", handler=lambda d: {"echo": d.get("msg", "")}, price_per_call_usd=0.002),
18
+ Capability("test.async@v1", "async", handler=_async_handler),
19
+ ],
20
+ )
21
+
22
+
23
+ async def _async_handler(d):
24
+ return {"doubled": d.get("n", 0) * 2}
25
+
26
+
27
+ class TestProtocol:
28
+ def test_manifest_self_verifies(self, tmp_path):
29
+ proto = Protocol(_spec(tmp_path))
30
+ m = proto.manifest()
31
+ assert m["capabilities_count"] == 2
32
+ assert proto.signer.verify_manifest_signature(m) is True
33
+
34
+ @pytest.mark.asyncio
35
+ async def test_invoke_envelope_and_receipt(self, tmp_path):
36
+ proto = Protocol(_spec(tmp_path))
37
+ r = await proto.invoke("test.echo@v1", {"msg": "hi"})
38
+ assert r["output"] == {"echo": "hi"}
39
+ assert r["price_usd"] == 0.002
40
+ assert len(r["provenance"]["input_hash"]) == 64
41
+ assert proto.signer.verify_receipt(r["receipt"]) is True
42
+
43
+ @pytest.mark.asyncio
44
+ async def test_async_handler(self, tmp_path):
45
+ proto = Protocol(_spec(tmp_path))
46
+ r = await proto.invoke("test.async@v1", {"n": 21})
47
+ assert r["output"] == {"doubled": 42}
48
+
49
+ def test_unknown_capability_raises(self, tmp_path):
50
+ with pytest.raises(ValueError):
51
+ Protocol(_spec(tmp_path)).spec.capability("nope@v1")
52
+
53
+ @pytest.mark.asyncio
54
+ async def test_measured_metrics_after_invoke(self, tmp_path):
55
+ proto = Protocol(_spec(tmp_path))
56
+ await proto.invoke("test.echo@v1", {"msg": "x"})
57
+ tool = next(t for t in proto.manifest()["tools"] if t["capability_id"] == "test.echo@v1")
58
+ assert tool["metrics_source"] == "measured" and tool["calls_observed"] >= 1
59
+
60
+
61
+ class TestApp:
62
+ @pytest.mark.asyncio
63
+ async def test_endpoints(self, tmp_path):
64
+ app = create_app(_spec(tmp_path))
65
+ transport = ASGITransport(app=app)
66
+ async with AsyncClient(transport=transport, base_url="http://t") as c:
67
+ assert (await c.get("/api/health")).json()["status"] == "ok"
68
+ wk = (await c.get("/.well-known/ai-market.json")).json()
69
+ assert wk["protocol_version"] == "v2" and wk["signer_public_key"]
70
+ inv = (await c.post("/ai-market/v2/invoke", json={"capability_id": "test.echo@v1", "input": {"msg": "yo"}})).json()
71
+ assert inv["ok"] is True and inv["output"] == {"echo": "yo"}
72
+ bad = (await c.post("/ai-market/v2/invoke", json={"capability_id": "x@v1", "input": {}})).json()
73
+ assert bad["ok"] is False
74
+
75
+
76
+ class TestSigningPQC:
77
+ def test_hybrid_when_enabled(self, tmp_path):
78
+ from oracle_core.signing import pqc_available
79
+
80
+ if not pqc_available():
81
+ pytest.skip("dilithium-py not installed")
82
+ s = Signer(tmp_path / "k", pqc=True)
83
+ sig = s.sign_payload("a|b")
84
+ assert sig.get("pq_algorithm") == "ml-dsa-65"
85
+ assert Signer.verify_signature_object("a|b", sig) is True
86
+ bad = dict(sig)
87
+ bad["pq_value"] = "AA" + sig["pq_value"][2:]
88
+ assert Signer.verify_signature_object("a|b", bad) is False
89
+
90
+
91
+ # ── Invoke error translation ─────────────────────────────────────
92
+ # Only ValueError used to become {"ok": false, "error": …}; every other way a handler can
93
+ # say "no" escaped as a bare 500 Internal Server Error with an empty body. A caller that
94
+ # read the schema and mistyped one field got nothing to correct, and a federated refusal
95
+ # ("Unknown capability: platon.verify@v1") was replaced by silence.
96
+
97
+ def _err_spec(tmp_path):
98
+ def missing_field(d):
99
+ return {"v": d["required_field"]} # KeyError
100
+
101
+ def bad_input(d):
102
+ raise ValueError("points must be a list of [x, y] pairs")
103
+
104
+ def upstream_refused(d):
105
+ raise RuntimeError("Unknown capability: platon.verify@v1")
106
+
107
+ def genuine_fault(d):
108
+ return 1 / 0 # ZeroDivisionError
109
+
110
+ return OracleSpec(
111
+ name="Err Oracle", product_id="prod-err", description="err",
112
+ public_url="http://localhost:9999", categories=["test"],
113
+ signing_key_path=str(tmp_path / "errkey"),
114
+ capabilities=[
115
+ Capability("err.missing@v1", "missing", handler=missing_field),
116
+ Capability("err.bad@v1", "bad", handler=bad_input),
117
+ Capability("err.upstream@v1", "upstream", handler=upstream_refused),
118
+ Capability("err.fault@v1", "fault", handler=genuine_fault),
119
+ ],
120
+ )
121
+
122
+
123
+ @pytest.mark.asyncio
124
+ @pytest.mark.parametrize(
125
+ "cap,needle",
126
+ [
127
+ ("err.missing@v1", "missing required input field: required_field"),
128
+ ("err.bad@v1", "points must be a list of [x, y] pairs"),
129
+ ("err.upstream@v1", "Unknown capability: platon.verify@v1"),
130
+ ],
131
+ )
132
+ async def test_refusals_reach_the_caller_with_their_reason(tmp_path, cap, needle):
133
+ app = create_app(_err_spec(tmp_path))
134
+ async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c:
135
+ r = await c.post("/ai-market/v2/invoke", json={"capability_id": cap, "input": {}})
136
+ assert r.status_code == 200, r.text
137
+ body = r.json()
138
+ assert body["ok"] is False
139
+ assert needle in body["error"], body
140
+
141
+
142
+ @pytest.mark.asyncio
143
+ async def test_a_real_fault_stays_a_5xx_but_says_what_broke(tmp_path):
144
+ """A crash must not be laundered into a 200 — but an empty body is indistinguishable
145
+ from a dead process, so the type and message travel with the 500."""
146
+ app = create_app(_err_spec(tmp_path))
147
+ async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c:
148
+ r = await c.post("/ai-market/v2/invoke", json={"capability_id": "err.fault@v1", "input": {}})
149
+ assert r.status_code == 500
150
+ assert "ZeroDivisionError" in r.text
151
+
152
+
153
+ @pytest.mark.asyncio
154
+ async def test_manifest_validates_against_the_published_protocol_schema(tmp_path):
155
+ """The whole point of a manifest is that a hub will accept it.
156
+
157
+ `p50_latency_ms` is declared `integer` in aimarket-protocol/schemas/manifest.json.
158
+ oracle_core emitted `round(p50, 2)`, so once a capability had been called and its
159
+ measured latency was fractional (2261.84), every hub rejected the entire manifest and
160
+ federated none of the oracle's capabilities.
161
+ """
162
+ import json
163
+ from pathlib import Path
164
+
165
+ import jsonschema
166
+
167
+ schema_path = (
168
+ Path(__file__).resolve().parents[3] / "aimarket-protocol" / "schemas" / "manifest.json"
169
+ )
170
+ if not schema_path.is_file():
171
+ pytest.skip(f"protocol schema not present at {schema_path}")
172
+
173
+ app = create_app(_spec(tmp_path))
174
+ async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c:
175
+ # Call one capability first so the metrics path (not just the declared default)
176
+ # is what gets serialised — that is the shape that actually broke.
177
+ await c.post("/ai-market/v2/invoke", json={"capability_id": "test.echo@v1", "input": {"msg": "x"}})
178
+ manifest = (await c.get("/ai-market/v2/manifest")).json()
179
+
180
+ for tool in manifest.get("tools") or []:
181
+ assert isinstance(tool["p50_latency_ms"], int), tool
182
+ jsonschema.validate(manifest, json.loads(schema_path.read_text()))
183
+
184
+
185
+ @pytest.mark.asyncio
186
+ async def test_the_hub_can_verify_an_oracle_manifest_signature(tmp_path):
187
+ """Cross-implementation check: the hub's verifier against an oracle's signature.
188
+
189
+ These are two separate codebases signing the same canonical string, and they had
190
+ drifted — the hub added a fifth field (`by_hub_hash`) to stop a relay tampering with
191
+ peer trust, oracle_core kept signing four, and so EVERY oracle manifest was rejected
192
+ with "Invalid manifest signature". Federation was silently dead; the only federated
193
+ rows in the live catalogue predated the hub's change. A unit test inside either
194
+ package would have passed — only checking one against the other catches this.
195
+ """
196
+ import sys
197
+ from pathlib import Path
198
+
199
+ hub_pkg = Path(__file__).resolve().parents[3] / "aimarket-hub"
200
+ if not (hub_pkg / "aimarket_hub" / "signing.py").is_file():
201
+ pytest.skip("aimarket-hub not available in this checkout")
202
+ sys.path.insert(0, str(hub_pkg))
203
+ try:
204
+ from aimarket_hub.signing import Signer as HubSigner
205
+ except Exception as exc: # noqa: BLE001 — hub deps may be absent in the oracle venv
206
+ pytest.skip(f"aimarket_hub not importable: {exc}")
207
+
208
+ app = create_app(_spec(tmp_path))
209
+ async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c:
210
+ manifest = (await c.get("/ai-market/v2/manifest")).json()
211
+
212
+ oracle_key = manifest["signature"]["public_key"]
213
+ hub_signer = HubSigner(str(tmp_path / "hubkey"))
214
+ assert hub_signer.verify_manifest_signature(manifest, oracle_key), (
215
+ "the hub rejects this oracle's manifest — the canonical forms have diverged again"
216
+ )
@@ -0,0 +1,72 @@
1
+ """Every oracle must depend on the shared library by the name this repo actually builds.
2
+
3
+ On 2026-07-28 all 17 oracles declared `dependencies = ["oracle-core"]`, unpinned. That name
4
+ belongs to an unrelated project on PyPI which installs the same top-level `oracle_core`
5
+ module, so `pip install oracles/chronos` in a clean environment resolved it from the index
6
+ and installed a stranger's package — flask, pandas and scikit-learn came with it — ending in
7
+ `ImportError: cannot import name 'Capability' from 'oracle_core'`. The family image never
8
+ noticed: it installs core and every oracle in one pip invocation, where the local project
9
+ satisfies the requirement before the index is consulted.
10
+
11
+ That is the whole failure mode — a dependency name nothing in the repo provides is not an
12
+ error at declaration time, only at install time, and only outside the one environment we
13
+ routinely build. So the check is static and needs no network: the name the oracles ask for
14
+ must be the name core publishes.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import pathlib
20
+ import re
21
+
22
+ import pytest
23
+
24
+ ORACLES = pathlib.Path(__file__).resolve().parents[2] / "oracles"
25
+ CORE_PYPROJECT = pathlib.Path(__file__).resolve().parents[1] / "pyproject.toml"
26
+
27
+ _NAME = re.compile(r'^\s*name\s*=\s*["\']([^"\']+)["\']', re.M)
28
+ _DEPS = re.compile(r"^dependencies\s*=\s*\[(.*?)\]", re.M | re.S)
29
+ # "aimarket-oracle-core>=0.2" -> "aimarket-oracle-core"
30
+ _REQ = re.compile(r'["\']\s*([A-Za-z0-9._-]+)')
31
+
32
+
33
+ def _dist_name(pyproject: pathlib.Path) -> str:
34
+ m = _NAME.search(pyproject.read_text())
35
+ assert m, f"{pyproject} declares no [project] name"
36
+ return m.group(1).lower().replace("_", "-")
37
+
38
+
39
+ def _dependency_names(pyproject: pathlib.Path) -> list[str]:
40
+ m = _DEPS.search(pyproject.read_text())
41
+ if not m:
42
+ return []
43
+ return [r.lower().replace("_", "-") for r in _REQ.findall(m.group(1))]
44
+
45
+
46
+ CORE_DIST = _dist_name(CORE_PYPROJECT)
47
+ ORACLE_PYPROJECTS = sorted(ORACLES.glob("*/pyproject.toml"))
48
+
49
+
50
+ def test_the_oracle_tree_was_found():
51
+ """A bad glob is how gaia slipped through the first sweep of this same fix."""
52
+ assert len(ORACLE_PYPROJECTS) >= 17, f"only found {len(ORACLE_PYPROJECTS)} oracles under {ORACLES}"
53
+
54
+
55
+ def test_core_is_namespaced():
56
+ assert CORE_DIST.startswith("aimarket-"), (
57
+ f"core publishes as {CORE_DIST!r}. Unprefixed names collide with strangers on PyPI; "
58
+ "keep the shared library inside the namespace this project owns."
59
+ )
60
+
61
+
62
+ @pytest.mark.parametrize("pyproject", ORACLE_PYPROJECTS, ids=lambda p: p.parent.name)
63
+ def test_oracle_depends_on_core_by_the_name_core_publishes(pyproject):
64
+ deps = _dependency_names(pyproject)
65
+ if CORE_DIST in deps:
66
+ return
67
+ stale = [d for d in deps if "oracle-core" in d or d == "oracle_core"]
68
+ pytest.fail(
69
+ f"{pyproject.parent.name} depends on {stale or deps} but core publishes as "
70
+ f"{CORE_DIST!r}. An unresolvable-locally name is fetched from PyPI, where "
71
+ f"`oracle-core` is somebody else's project."
72
+ )