chore: initialize monorepo with project scaffolding, configs, and CI setup

This commit is contained in:
oib
2025-09-27 06:05:25 +02:00
commit c1926136fb
171 changed files with 13708 additions and 0 deletions

View File

@ -0,0 +1,14 @@
[project]
name = "aitbc-sdk"
version = "0.1.0"
description = "AITBC client SDK for interacting with coordinator services"
requires-python = ">=3.11"
dependencies = [
"httpx>=0.27.0",
"pydantic>=2.7.0",
"aitbc-crypto @ file://../aitbc-crypto"
]
[build-system]
requires = ["setuptools", "wheel"]
build-backend = "setuptools.build_meta"

View File

@ -0,0 +1,17 @@
"""AITBC Python SDK utilities."""
from .receipts import (
CoordinatorReceiptClient,
ReceiptVerification,
SignatureValidation,
verify_receipt,
verify_receipts,
)
__all__ = [
"CoordinatorReceiptClient",
"ReceiptVerification",
"SignatureValidation",
"verify_receipt",
"verify_receipts",
]

View File

@ -0,0 +1,95 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, Iterable, List, Optional
import httpx
import base64
from aitbc_crypto.signing import ReceiptVerifier
@dataclass
class SignatureValidation:
key_id: str
valid: bool
algorithm: str = "Ed25519"
@dataclass
class ReceiptVerification:
receipt: Dict[str, Any]
miner_signature: SignatureValidation
coordinator_attestations: List[SignatureValidation]
@property
def verified(self) -> bool:
if not self.miner_signature.valid:
return False
return all(att.valid for att in self.coordinator_attestations)
class CoordinatorReceiptClient:
def __init__(self, base_url: str, api_key: str, timeout: float = 10.0) -> None:
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.timeout = timeout
def _client(self) -> httpx.Client:
return httpx.Client(
base_url=self.base_url,
timeout=self.timeout,
headers={"X-Api-Key": self.api_key},
)
def fetch_latest(self, job_id: str) -> Optional[Dict[str, Any]]:
with self._client() as client:
resp = client.get(f"/v1/jobs/{job_id}/receipt")
if resp.status_code == 404:
return None
resp.raise_for_status()
return resp.json()
def fetch_history(self, job_id: str) -> List[Dict[str, Any]]:
with self._client() as client:
resp = client.get(f"/v1/jobs/{job_id}/receipts")
resp.raise_for_status()
data = resp.json()
if isinstance(data, dict) and isinstance(data.get("items"), list):
return data["items"]
raise ValueError("unexpected receipt history response shape")
def _verify_signature(payload: Dict[str, Any], signature: Dict[str, Any]) -> SignatureValidation:
key_id = signature.get("key_id", "")
verifier = ReceiptVerifier(_decode_key(key_id))
valid = verifier.verify(payload, signature)
return SignatureValidation(key_id=key_id, valid=valid)
def verify_receipt(receipt: Dict[str, Any]) -> ReceiptVerification:
payload = {k: v for k, v in receipt.items() if k not in {"signature", "attestations"}}
miner_sig = receipt.get("signature") or {}
miner_validation = _verify_signature(payload, miner_sig)
attestations = receipt.get("attestations") or []
att_validations = [
_verify_signature(payload, att) for att in attestations if isinstance(att, dict)
]
return ReceiptVerification(
receipt=receipt,
miner_signature=miner_validation,
coordinator_attestations=att_validations,
)
def verify_receipts(receipts: Iterable[Dict[str, Any]]) -> List[ReceiptVerification]:
return [verify_receipt(receipt) for receipt in receipts]
def _decode_key(data: str) -> bytes:
if not data:
return b""
padding = "=" * (-len(data) % 4)
return base64.urlsafe_b64decode(data + padding)

View File

@ -0,0 +1,141 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, List, Optional
import pytest
from nacl.signing import SigningKey
from aitbc_crypto.signing import ReceiptSigner
from aitbc_sdk.receipts import (
CoordinatorReceiptClient,
ReceiptVerification,
verify_receipt,
verify_receipts,
)
@pytest.fixture()
def sample_payload() -> Dict[str, object]:
return {
"version": "1.0",
"receipt_id": "rcpt-1",
"job_id": "job-123",
"provider": "miner-abc",
"client": "client-xyz",
"units": 1.0,
"unit_type": "gpu_seconds",
"price": 3.5,
"started_at": 1700000000,
"completed_at": 1700000005,
"metadata": {
"job_payload": {"task": "render"},
"job_constraints": {},
"result": {"duration": 5},
"metrics": {"duration_ms": 5000},
},
}
def _sign_receipt(payload: Dict[str, object], key: SigningKey) -> Dict[str, object]:
signer = ReceiptSigner(key.encode())
receipt = dict(payload)
receipt["signature"] = signer.sign(payload)
return receipt
def test_verify_receipt_success(sample_payload: Dict[str, object]) -> None:
signing_key = SigningKey.generate()
receipt = _sign_receipt(sample_payload, signing_key)
result = verify_receipt(receipt)
assert isinstance(result, ReceiptVerification)
assert result.miner_signature.valid is True
assert result.verified is True
def test_verify_receipt_failure(sample_payload: Dict[str, object]) -> None:
signing_key = SigningKey.generate()
receipt = _sign_receipt(sample_payload, signing_key)
receipt["metadata"] = {"job_payload": {"task": "tampered"}}
result = verify_receipt(receipt)
assert result.miner_signature.valid is False
assert result.verified is False
def test_verify_receipts_batch(sample_payload: Dict[str, object]) -> None:
signing_key = SigningKey.generate()
receipt = _sign_receipt(sample_payload, signing_key)
results = verify_receipts([receipt, receipt])
assert len(results) == 2
assert all(item.verified for item in results)
@dataclass
class _DummyResponse:
status_code: int
data: object
def json(self) -> object:
return self.data
def raise_for_status(self) -> None:
if self.status_code >= 400:
raise Exception(f"HTTP {self.status_code}")
class _DummyClient:
def __init__(self, responses: List[_DummyResponse]):
self._responses = responses
def get(self, url: str, *args, **kwargs) -> _DummyResponse:
if not self._responses:
raise AssertionError("no more responses configured")
return self._responses.pop(0)
def __enter__(self) -> "_DummyClient":
return self
def __exit__(self, exc_type, exc, tb) -> None:
pass
def test_coordinator_receipt_client_latest(monkeypatch, sample_payload: Dict[str, object]) -> None:
signing_key = SigningKey.generate()
receipt = _sign_receipt(sample_payload, signing_key)
def _mock_client(self) -> _DummyClient:
return _DummyClient([_DummyResponse(200, receipt)])
monkeypatch.setattr(CoordinatorReceiptClient, "_client", _mock_client)
client = CoordinatorReceiptClient("https://coordinator", "api")
fetched = client.fetch_latest("job-123")
assert fetched == receipt
def test_coordinator_receipt_client_history(monkeypatch, sample_payload: Dict[str, object]) -> None:
signing_key = SigningKey.generate()
receipts = [_sign_receipt(sample_payload, signing_key)]
def _mock_client(self) -> _DummyClient:
return _DummyClient([_DummyResponse(200, {"items": receipts})])
monkeypatch.setattr(CoordinatorReceiptClient, "_client", _mock_client)
client = CoordinatorReceiptClient("https://coordinator", "api")
history = client.fetch_history("job-123")
assert history == receipts
def test_coordinator_receipt_client_latest_404(monkeypatch) -> None:
def _mock_client(self) -> _DummyClient:
return _DummyClient([_DummyResponse(404, {})])
monkeypatch.setattr(CoordinatorReceiptClient, "_client", _mock_client)
client = CoordinatorReceiptClient("https://coordinator", "api")
assert client.fetch_latest("job-missing") is None