chore: initialize monorepo with project scaffolding, configs, and CI setup
This commit is contained in:
32
apps/wallet-daemon/README.md
Normal file
32
apps/wallet-daemon/README.md
Normal 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.
|
||||
5
apps/wallet-daemon/src/app/__init__.py
Normal file
5
apps/wallet-daemon/src/app/__init__.py
Normal file
@ -0,0 +1,5 @@
|
||||
"""Wallet daemon FastAPI application package."""
|
||||
|
||||
from .main import create_app
|
||||
|
||||
__all__ = ["create_app"]
|
||||
49
apps/wallet-daemon/src/app/api_jsonrpc.py
Normal file
49
apps/wallet-daemon/src/app/api_jsonrpc.py
Normal 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)
|
||||
49
apps/wallet-daemon/src/app/api_rest.py
Normal file
49
apps/wallet-daemon/src/app/api_rest.py
Normal 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)
|
||||
62
apps/wallet-daemon/src/app/crypto/encryption.py
Normal file
62
apps/wallet-daemon/src/app/crypto/encryption.py
Normal 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
|
||||
26
apps/wallet-daemon/src/app/deps.py
Normal file
26
apps/wallet-daemon/src/app/deps.py
Normal 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()
|
||||
51
apps/wallet-daemon/src/app/keystore/service.py
Normal file
51
apps/wallet-daemon/src/app/keystore/service.py
Normal 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
|
||||
17
apps/wallet-daemon/src/app/main.py
Normal file
17
apps/wallet-daemon/src/app/main.py
Normal 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()
|
||||
45
apps/wallet-daemon/src/app/models/__init__.py
Normal file
45
apps/wallet-daemon/src/app/models/__init__.py
Normal 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]
|
||||
5
apps/wallet-daemon/src/app/receipts/__init__.py
Normal file
5
apps/wallet-daemon/src/app/receipts/__init__.py
Normal file
@ -0,0 +1,5 @@
|
||||
"""Receipt verification helpers for the wallet daemon."""
|
||||
|
||||
from .service import ReceiptValidationResult, ReceiptVerifierService
|
||||
|
||||
__all__ = ["ReceiptValidationResult", "ReceiptVerifierService"]
|
||||
58
apps/wallet-daemon/src/app/receipts/service.py
Normal file
58
apps/wallet-daemon/src/app/receipts/service.py
Normal 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),
|
||||
)
|
||||
23
apps/wallet-daemon/src/app/settings.py
Normal file
23
apps/wallet-daemon/src/app/settings.py
Normal 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()
|
||||
81
apps/wallet-daemon/tests/test_receipts.py
Normal file
81
apps/wallet-daemon/tests/test_receipts.py
Normal 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"
|
||||
Reference in New Issue
Block a user