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,32 @@
# Wallet Daemon
## Purpose & Scope
Local FastAPI service that manages encrypted keys, signs transactions/receipts, and exposes wallet RPC endpoints. Reference `docs/bootstrap/wallet_daemon.md` for the implementation plan.
## Development Setup
- Create a Python virtual environment under `apps/wallet-daemon/.venv` or use Poetry.
- Install dependencies via Poetry (preferred):
```bash
poetry install
```
- Copy/create `.env` and configure coordinator access:
```bash
cp .env.example .env # create file if missing
```
- `COORDINATOR_BASE_URL` (default `http://localhost:8011`)
- `COORDINATOR_API_KEY` (development key to verify receipts)
- Run the service locally:
```bash
poetry run uvicorn app.main:app --host 0.0.0.0 --port 8071 --reload
```
- REST receipt endpoints:
- `GET /v1/receipts/{job_id}` (latest receipt + signature validations)
- `GET /v1/receipts/{job_id}/history` (full history + validations)
- JSON-RPC interface (`POST /rpc`):
- Method `receipts.verify_latest`
- Method `receipts.verify_history`
- Keystore scaffolding:
- `KeystoreService` uses Argon2id + XChaCha20-Poly1305 via `app/crypto/encryption.py` (in-memory for now).
- Future milestones will add persistent storage and wallet lifecycle routes.

View File

@ -0,0 +1,5 @@
"""Wallet daemon FastAPI application package."""
from .main import create_app
__all__ = ["create_app"]

View File

@ -0,0 +1,49 @@
from __future__ import annotations
from typing import Any, Dict, Optional
from fastapi import APIRouter, Depends
from .deps import get_receipt_service
from .models import ReceiptVerificationModel, from_validation_result
from .receipts.service import ReceiptVerifierService
router = APIRouter(tags=["jsonrpc"])
def _response(result: Optional[Dict[str, Any]] = None, error: Optional[Dict[str, Any]] = None, *, request_id: Any = None) -> Dict[str, Any]:
payload: Dict[str, Any] = {"jsonrpc": "2.0", "id": request_id}
if error is not None:
payload["error"] = error
else:
payload["result"] = result
return payload
@router.post("/rpc", summary="JSON-RPC endpoint")
def handle_jsonrpc(
request: Dict[str, Any],
service: ReceiptVerifierService = Depends(get_receipt_service),
) -> Dict[str, Any]:
method = request.get("method")
params = request.get("params") or {}
request_id = request.get("id")
if method == "receipts.verify_latest":
job_id = params.get("job_id")
if not job_id:
return _response(error={"code": -32602, "message": "job_id required"}, request_id=request_id)
result = service.verify_latest(str(job_id))
if result is None:
return _response(error={"code": -32004, "message": "receipt not found"}, request_id=request_id)
model = from_validation_result(result)
return _response(result=model.model_dump(), request_id=request_id)
if method == "receipts.verify_history":
job_id = params.get("job_id")
if not job_id:
return _response(error={"code": -32602, "message": "job_id required"}, request_id=request_id)
results = [from_validation_result(item).model_dump() for item in service.verify_history(str(job_id))]
return _response(result={"items": results}, request_id=request_id)
return _response(error={"code": -32601, "message": "Method not found"}, request_id=request_id)

View File

@ -0,0 +1,49 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from .deps import get_receipt_service
from .models import (
ReceiptVerificationListResponse,
ReceiptVerificationModel,
ReceiptVerifyResponse,
SignatureValidationModel,
from_validation_result,
)
from .receipts.service import ReceiptValidationResult, ReceiptVerifierService
router = APIRouter(prefix="/v1", tags=["receipts"])
def _result_to_response(result: ReceiptValidationResult) -> ReceiptVerifyResponse:
payload = from_validation_result(result)
return ReceiptVerifyResponse(result=payload)
@router.get(
"/receipts/{job_id}",
response_model=ReceiptVerifyResponse,
summary="Verify latest receipt for a job",
)
def verify_latest_receipt(
job_id: str,
service: ReceiptVerifierService = Depends(get_receipt_service),
) -> ReceiptVerifyResponse:
result = service.verify_latest(job_id)
if result is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="receipt not found")
return _result_to_response(result)
@router.get(
"/receipts/{job_id}/history",
response_model=ReceiptVerificationListResponse,
summary="Verify all historical receipts for a job",
)
def verify_receipt_history(
job_id: str,
service: ReceiptVerifierService = Depends(get_receipt_service),
) -> ReceiptVerificationListResponse:
results = service.verify_history(job_id)
items = [from_validation_result(result) for result in results]
return ReceiptVerificationListResponse(items=items)

View File

@ -0,0 +1,62 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
from argon2.low_level import Type as Argon2Type, hash_secret_raw
from nacl.bindings import (
crypto_aead_xchacha20poly1305_ietf_decrypt,
crypto_aead_xchacha20poly1305_ietf_encrypt,
)
class EncryptionError(Exception):
"""Raised when encryption or decryption fails."""
@dataclass
class EncryptionSuite:
"""Argon2id + XChaCha20-Poly1305 helper functions."""
salt_bytes: int = 16
nonce_bytes: int = 24
key_bytes: int = 32
argon_time_cost: int = 3
argon_memory_cost: int = 64 * 1024 # kibibytes
argon_parallelism: int = 2
def _derive_key(self, *, password: str, salt: bytes) -> bytes:
password_bytes = password.encode("utf-8")
return hash_secret_raw(
secret=password_bytes,
salt=salt,
time_cost=self.argon_time_cost,
memory_cost=self.argon_memory_cost,
parallelism=self.argon_parallelism,
hash_len=self.key_bytes,
type=Argon2Type.ID,
)
def encrypt(self, *, password: str, plaintext: bytes, salt: bytes, nonce: bytes) -> bytes:
key = self._derive_key(password=password, salt=salt)
try:
return crypto_aead_xchacha20poly1305_ietf_encrypt(
message=plaintext,
aad=b"",
nonce=nonce,
key=key,
)
except Exception as exc: # pragma: no cover
raise EncryptionError("encryption failed") from exc
def decrypt(self, *, password: str, ciphertext: bytes, salt: bytes, nonce: bytes) -> bytes:
key = self._derive_key(password=password, salt=salt)
try:
return crypto_aead_xchacha20poly1305_ietf_decrypt(
ciphertext=ciphertext,
aad=b"",
nonce=nonce,
key=key,
)
except Exception as exc:
raise EncryptionError("decryption failed") from exc

View File

@ -0,0 +1,26 @@
from __future__ import annotations
from functools import lru_cache
from fastapi import Depends
from .keystore.service import KeystoreService
from .receipts.service import ReceiptVerifierService
from .settings import Settings, settings
@lru_cache
def get_settings() -> Settings:
return settings
def get_receipt_service(config: Settings = Depends(get_settings)) -> ReceiptVerifierService:
return ReceiptVerifierService(
coordinator_url=config.coordinator_base_url,
api_key=config.coordinator_api_key,
)
@lru_cache
def get_keystore() -> KeystoreService:
return KeystoreService()

View File

@ -0,0 +1,51 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, List, Optional
from secrets import token_bytes
from ..crypto.encryption import EncryptionSuite, EncryptionError
@dataclass
class WalletRecord:
wallet_id: str
salt: bytes
nonce: bytes
ciphertext: bytes
metadata: Dict[str, str]
class KeystoreService:
"""In-memory keystore with Argon2id + XChaCha20-Poly1305 encryption."""
def __init__(self, encryption: Optional[EncryptionSuite] = None) -> None:
self._wallets: Dict[str, WalletRecord] = {}
self._encryption = encryption or EncryptionSuite()
def list_wallets(self) -> List[str]:
return list(self._wallets.keys())
def get_wallet(self, wallet_id: str) -> Optional[WalletRecord]:
return self._wallets.get(wallet_id)
def create_wallet(self, wallet_id: str, password: str, plaintext: bytes, metadata: Optional[Dict[str, str]] = None) -> WalletRecord:
salt = token_bytes(self._encryption.salt_bytes)
nonce = token_bytes(self._encryption.nonce_bytes)
ciphertext = self._encryption.encrypt(password=password, plaintext=plaintext, salt=salt, nonce=nonce)
record = WalletRecord(wallet_id=wallet_id, salt=salt, nonce=nonce, ciphertext=ciphertext, metadata=metadata or {})
self._wallets[wallet_id] = record
return record
def unlock_wallet(self, wallet_id: str, password: str) -> bytes:
record = self._wallets.get(wallet_id)
if record is None:
raise KeyError("wallet not found")
try:
return self._encryption.decrypt(password=password, ciphertext=record.ciphertext, salt=record.salt, nonce=record.nonce)
except EncryptionError as exc:
raise ValueError("failed to decrypt wallet") from exc
def delete_wallet(self, wallet_id: str) -> bool:
return self._wallets.pop(wallet_id, None) is not None

View File

@ -0,0 +1,17 @@
from __future__ import annotations
from fastapi import FastAPI
from .api_jsonrpc import router as jsonrpc_router
from .api_rest import router as receipts_router
from .settings import settings
def create_app() -> FastAPI:
app = FastAPI(title=settings.app_name, debug=settings.debug)
app.include_router(receipts_router)
app.include_router(jsonrpc_router)
return app
app = create_app()

View File

@ -0,0 +1,45 @@
from __future__ import annotations
from typing import List
from aitbc_sdk import SignatureValidation
from pydantic import BaseModel
class SignatureValidationModel(BaseModel):
key_id: str
alg: str = "Ed25519"
valid: bool
class ReceiptVerificationModel(BaseModel):
job_id: str
receipt_id: str
miner_signature: SignatureValidationModel
coordinator_attestations: List[SignatureValidationModel]
all_valid: bool
class ReceiptVerifyResponse(BaseModel):
result: ReceiptVerificationModel
def _signature_to_model(sig: SignatureValidation | SignatureValidationModel) -> SignatureValidationModel:
if isinstance(sig, SignatureValidationModel):
return sig
return SignatureValidationModel(key_id=sig.key_id, alg=sig.algorithm, valid=sig.valid)
def from_validation_result(result) -> ReceiptVerificationModel:
return ReceiptVerificationModel(
job_id=result.job_id,
receipt_id=result.receipt_id,
miner_signature=_signature_to_model(result.miner_signature),
coordinator_attestations=[_signature_to_model(att) for att in result.coordinator_attestations],
all_valid=result.all_valid,
)
class ReceiptVerificationListResponse(BaseModel):
items: List[ReceiptVerificationModel]

View File

@ -0,0 +1,5 @@
"""Receipt verification helpers for the wallet daemon."""
from .service import ReceiptValidationResult, ReceiptVerifierService
__all__ = ["ReceiptValidationResult", "ReceiptVerifierService"]

View File

@ -0,0 +1,58 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import List, Optional
from aitbc_sdk import (
CoordinatorReceiptClient,
ReceiptVerification,
SignatureValidation,
verify_receipt,
verify_receipts,
)
@dataclass
class ReceiptValidationResult:
job_id: str
receipt_id: str
receipt: dict
miner_signature: SignatureValidation
coordinator_attestations: List[SignatureValidation]
@property
def miner_valid(self) -> bool:
return self.miner_signature.valid
@property
def all_valid(self) -> bool:
return self.miner_signature.valid and all(att.valid for att in self.coordinator_attestations)
class ReceiptVerifierService:
"""Wraps `aitbc_sdk` receipt verification for wallet daemon workflows."""
def __init__(self, coordinator_url: str, api_key: str, timeout: float = 10.0) -> None:
self.client = CoordinatorReceiptClient(coordinator_url, api_key, timeout=timeout)
def verify_latest(self, job_id: str) -> Optional[ReceiptValidationResult]:
receipt = self.client.fetch_latest(job_id)
if receipt is None:
return None
verification = verify_receipt(receipt)
return self._to_result(verification)
def verify_history(self, job_id: str) -> List[ReceiptValidationResult]:
receipts = self.client.fetch_history(job_id)
verifications = verify_receipts(receipts)
return [self._to_result(item) for item in verifications]
@staticmethod
def _to_result(verification: ReceiptVerification) -> ReceiptValidationResult:
return ReceiptValidationResult(
job_id=str(verification.receipt.get("job_id")),
receipt_id=str(verification.receipt.get("receipt_id")),
receipt=verification.receipt,
miner_signature=verification.miner_signature,
coordinator_attestations=list(verification.coordinator_attestations),
)

View File

@ -0,0 +1,23 @@
from __future__ import annotations
from pydantic import Field
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
"""Runtime configuration for the wallet daemon service."""
app_name: str = Field(default="AITBC Wallet Daemon")
debug: bool = Field(default=False)
coordinator_base_url: str = Field(default="http://localhost:8011", alias="COORDINATOR_BASE_URL")
coordinator_api_key: str = Field(default="client_dev_key_1", alias="COORDINATOR_API_KEY")
rest_prefix: str = Field(default="/v1", alias="REST_PREFIX")
class Config:
env_file = ".env"
case_sensitive = False
settings = Settings()

View File

@ -0,0 +1,81 @@
from __future__ import annotations
import pytest
from nacl.signing import SigningKey
from app.receipts import ReceiptValidationResult, ReceiptVerifierService
@pytest.fixture()
def sample_receipt() -> dict:
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": {},
}
class _DummyClient:
def __init__(self, latest=None, history=None):
self.latest = latest
self.history = history or []
def fetch_latest(self, job_id: str):
return self.latest
def fetch_history(self, job_id: str):
return list(self.history)
@pytest.fixture()
def signer():
return SigningKey.generate()
@pytest.fixture()
def signed_receipt(sample_receipt: dict, signer: SigningKey) -> dict:
from aitbc_crypto.signing import ReceiptSigner
receipt = dict(sample_receipt)
receipt["signature"] = ReceiptSigner(signer.encode()).sign(sample_receipt)
return receipt
def test_verify_latest_success(monkeypatch, signed_receipt: dict):
service = ReceiptVerifierService("http://coordinator", "api-key")
client = _DummyClient(latest=signed_receipt)
monkeypatch.setattr(service, "client", client)
result = service.verify_latest("job-123")
assert isinstance(result, ReceiptValidationResult)
assert result.job_id == "job-123"
assert result.receipt_id == "rcpt-1"
assert result.miner_valid is True
assert result.all_valid is True
def test_verify_latest_none(monkeypatch):
service = ReceiptVerifierService("http://coordinator", "api-key")
client = _DummyClient(latest=None)
monkeypatch.setattr(service, "client", client)
assert service.verify_latest("job-123") is None
def test_verify_history(monkeypatch, signed_receipt: dict):
service = ReceiptVerifierService("http://coordinator", "api-key")
client = _DummyClient(history=[signed_receipt])
monkeypatch.setattr(service, "client", client)
results = service.verify_history("job-123")
assert len(results) == 1
assert results[0].miner_valid is True
assert results[0].job_id == "job-123"