diff --git a/sdk/python/rustchain/__init__.py b/sdk/python/rustchain/__init__.py new file mode 100644 index 00000000..94d0e4e7 --- /dev/null +++ b/sdk/python/rustchain/__init__.py @@ -0,0 +1,23 @@ +"""RustChain Python SDK — async client for RustChain nodes.""" + +from .client import RustChainClient, ARCH_MULTIPLIERS +from .exceptions import ( + RustChainError, + RustChainHTTPError, + RustChainConnectionError, + RustChainTimeoutError, + RustChainNotFoundError, + RustChainAuthError, +) + +__version__ = "0.1.0" +__all__ = [ + "RustChainClient", + "ARCH_MULTIPLIERS", + "RustChainError", + "RustChainHTTPError", + "RustChainConnectionError", + "RustChainTimeoutError", + "RustChainNotFoundError", + "RustChainAuthError", +] diff --git a/sdk/python/rustchain/cli.py b/sdk/python/rustchain/cli.py new file mode 100644 index 00000000..88d1ce58 --- /dev/null +++ b/sdk/python/rustchain/cli.py @@ -0,0 +1,68 @@ +"""CLI wrapper: ``rustchain [args]``.""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import sys + +from .client import RustChainClient + + +def _print_json(data: object) -> None: + print(json.dumps(data, indent=2)) + + +async def _run(args: argparse.Namespace) -> None: + async with RustChainClient(node_url=args.node) as client: + if args.command == "health": + _print_json(await client.health()) + elif args.command == "epoch": + _print_json(await client.epoch()) + elif args.command == "miners": + _print_json(await client.miners()) + elif args.command == "balance": + _print_json(await client.balance(args.wallet_id)) + elif args.command == "attestation": + _print_json(await client.attestation_status(args.miner_id)) + elif args.command == "blocks": + _print_json(await client.explorer.blocks()) + elif args.command == "transactions": + _print_json(await client.explorer.transactions()) + + +def main() -> None: + parser = argparse.ArgumentParser( + prog="rustchain", + description="RustChain node CLI", + ) + parser.add_argument( + "--node", + default=None, + help="Node base URL (default: RUSTCHAIN_NODE_URL env or https://50.28.86.131)", + ) + sub = parser.add_subparsers(dest="command", required=True) + + sub.add_parser("health", help="Node health check") + sub.add_parser("epoch", help="Current epoch info") + sub.add_parser("miners", help="List active miners") + + p_balance = sub.add_parser("balance", help="Check wallet balance") + p_balance.add_argument("wallet_id", help="Wallet name / miner ID") + + p_attest = sub.add_parser("attestation", help="Check attestation status") + p_attest.add_argument("miner_id", help="Miner ID") + + sub.add_parser("blocks", help="Recent blocks") + sub.add_parser("transactions", help="Recent transactions") + + args = parser.parse_args() + try: + asyncio.run(_run(args)) + except KeyboardInterrupt: + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/sdk/python/rustchain/client.py b/sdk/python/rustchain/client.py new file mode 100644 index 00000000..a67823fd --- /dev/null +++ b/sdk/python/rustchain/client.py @@ -0,0 +1,247 @@ +"""Async RustChain client.""" + +from __future__ import annotations + +import os +import ssl +from typing import Any, Dict, List, Optional + +import httpx + +from .exceptions import ( + RustChainAuthError, + RustChainConnectionError, + RustChainHTTPError, + RustChainNotFoundError, + RustChainTimeoutError, +) +from .explorer import ExplorerClient + +_DEFAULT_NODE = "https://50.28.86.131" + +# Hardware antiquity multipliers — mirrors rustchain-miner/src/hardware/arch.rs. +# Used by callers to compute weighted epoch rewards before submitting attestations. +ARCH_MULTIPLIERS: Dict[str, float] = { + # Apple PowerPC (high antiquity) + "g4": 2.5, + "g5": 2.0, + "g3": 1.8, + # SPARC (Sun/Oracle workstation heritage) + "sparc": 2.4, + # MIPS (SGI, embedded systems heritage) + "mips": 2.2, + # ARM (early Cortex-A / pre-v8 era) + "arm": 1.6, + # IBM POWER8 (high-core RISC heritage) + "power8": 2.3, + # Other known architectures + "apple_silicon": 1.2, + "core2duo": 1.3, + "modern": 1.0, +} + + +def _build_ssl_context(verify: bool) -> ssl.SSLContext | bool: + """Return an SSLContext that skips verification, or True for normal TLS.""" + if not verify: + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + return ctx + return True + + +class RustChainClient: + """Async client for interacting with a RustChain node. + + Parameters + ---------- + node_url: + Base URL of the node. Defaults to the ``RUSTCHAIN_NODE_URL`` + environment variable or ``https://50.28.86.131``. + timeout: + Request timeout in seconds (default 30). + verify_ssl: + Whether to verify TLS certificates. Set to ``False`` when + connecting to a node served at a bare IP address. + """ + + def __init__( + self, + node_url: Optional[str] = None, + *, + timeout: float = 30.0, + verify_ssl: bool = False, + ) -> None: + self.node_url = ( + node_url + or os.environ.get("RUSTCHAIN_NODE_URL", _DEFAULT_NODE) + ).rstrip("/") + self._timeout = timeout + self._verify_ssl = verify_ssl + self._client: Optional[httpx.AsyncClient] = None + self.explorer = ExplorerClient(self) + + # ------------------------------------------------------------------ # + # Context-manager support # + # ------------------------------------------------------------------ # + + async def __aenter__(self) -> "RustChainClient": + self._client = httpx.AsyncClient( + base_url=self.node_url, + timeout=self._timeout, + verify=False if not self._verify_ssl else True, + ) + return self + + async def __aexit__(self, *_: Any) -> None: + if self._client: + await self._client.aclose() + self._client = None + + # ------------------------------------------------------------------ # + # Low-level helpers # + # ------------------------------------------------------------------ # + + def _get_client(self) -> httpx.AsyncClient: + if self._client is None: + self._client = httpx.AsyncClient( + base_url=self.node_url, + timeout=self._timeout, + verify=False if not self._verify_ssl else True, + ) + return self._client + + async def _get(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Any: + """Issue a GET request and return the parsed JSON body.""" + client = self._get_client() + try: + response = await client.get(endpoint, params=params) + except httpx.TimeoutException as exc: + raise RustChainTimeoutError(str(exc)) from exc + except httpx.ConnectError as exc: + raise RustChainConnectionError(str(exc)) from exc + return self._handle(response) + + async def _post(self, endpoint: str, json: Optional[Dict[str, Any]] = None) -> Any: + """Issue a POST request and return the parsed JSON body.""" + client = self._get_client() + try: + response = await client.post(endpoint, json=json) + except httpx.TimeoutException as exc: + raise RustChainTimeoutError(str(exc)) from exc + except httpx.ConnectError as exc: + raise RustChainConnectionError(str(exc)) from exc + return self._handle(response) + + @staticmethod + def _handle(response: httpx.Response) -> Any: + """Raise a typed exception for error status codes.""" + if response.status_code == 404: + raise RustChainNotFoundError() + if response.status_code in (401, 403): + raise RustChainAuthError() + if response.status_code >= 400: + raise RustChainHTTPError( + f"HTTP {response.status_code}: {response.text}", + status_code=response.status_code, + ) + return response.json() + + # ------------------------------------------------------------------ # + # Public API # + # ------------------------------------------------------------------ # + + async def health(self) -> Dict[str, Any]: + """Check node health. + + Returns: + dict: Health payload (``ok``, ``version``, ``uptime_s``, …). + """ + return await self._get("/health") + + async def epoch(self) -> Dict[str, Any]: + """Return current epoch information. + + Returns: + dict: Epoch payload (``epoch``, ``slot``, ``blocks_per_epoch``, …). + """ + return await self._get("/epoch") + + async def miners(self) -> List[Dict[str, Any]]: + """List active miners. + + Returns: + list: Miner records with hardware and attestation details. + """ + return await self._get("/api/miners") + + async def balance(self, wallet_id: str) -> Dict[str, Any]: + """Check the RTC balance for *wallet_id*. + + Parameters + ---------- + wallet_id: + The wallet name / miner ID to query. + + Returns: + dict: Balance payload. + """ + return await self._get("/wallet/balance", params={"miner_id": wallet_id}) + + async def transfer( + self, + from_address: str, + to_address: str, + amount_rtc: float, + nonce: str, + signature: str, + public_key: str, + ) -> Dict[str, Any]: + """Submit a signed RTC transfer. + + Parameters + ---------- + from_address: + Sender's wallet address. + to_address: + Recipient's wallet address. + amount_rtc: + Transfer amount in RTC (must be positive). + nonce: + Unique nonce preventing replay attacks (UUID v4 recommended). + signature: + Cryptographic signature over the canonical payload bytes. + public_key: + Hex-encoded public key corresponding to *from_address*. + + Returns: + dict: Transfer result payload. + + Raises: + ValueError: If *amount_rtc* is not positive. + """ + if amount_rtc <= 0: + raise ValueError(f"amount_rtc must be positive, got {amount_rtc}") + payload = { + "from_address": from_address, + "to_address": to_address, + "amount_rtc": amount_rtc, + "nonce": nonce, + "signature": signature, + "public_key": public_key, + } + return await self._post("/wallet/transfer/signed", json=payload) + + async def attestation_status(self, miner_id: str) -> Dict[str, Any]: + """Check the attestation status for *miner_id*. + + Parameters + ---------- + miner_id: + The miner whose attestation to query. + + Returns: + dict: Attestation status payload. + """ + return await self._get(f"/api/attestation/{miner_id}") diff --git a/sdk/python/rustchain/exceptions.py b/sdk/python/rustchain/exceptions.py new file mode 100644 index 00000000..97920edd --- /dev/null +++ b/sdk/python/rustchain/exceptions.py @@ -0,0 +1,37 @@ +"""Typed exceptions for the RustChain SDK.""" + +from typing import Optional + + +class RustChainError(Exception): + """Base exception for all RustChain SDK errors.""" + + def __init__(self, message: str, status_code: Optional[int] = None) -> None: + super().__init__(message) + self.status_code = status_code + + +class RustChainHTTPError(RustChainError): + """Raised when the API returns a non-2xx HTTP status.""" + + +class RustChainConnectionError(RustChainError): + """Raised when a network connection cannot be established.""" + + +class RustChainTimeoutError(RustChainError): + """Raised when a request exceeds the configured timeout.""" + + +class RustChainNotFoundError(RustChainHTTPError): + """Raised on HTTP 404 responses.""" + + def __init__(self, message: str = "Resource not found") -> None: + super().__init__(message, status_code=404) + + +class RustChainAuthError(RustChainHTTPError): + """Raised on HTTP 401/403 responses.""" + + def __init__(self, message: str = "Unauthorized") -> None: + super().__init__(message, status_code=401) diff --git a/sdk/python/rustchain/explorer.py b/sdk/python/rustchain/explorer.py new file mode 100644 index 00000000..47e0769d --- /dev/null +++ b/sdk/python/rustchain/explorer.py @@ -0,0 +1,26 @@ +"""Explorer sub-client for block/transaction queries.""" + +from typing import Any, Dict, List + + +class ExplorerClient: + """Provides access to the RustChain block explorer endpoints.""" + + def __init__(self, parent: Any) -> None: + self._parent = parent + + async def blocks(self) -> List[Dict[str, Any]]: + """Return recent blocks from the explorer. + + Returns: + list: Recent block records. + """ + return await self._parent._get("/api/explorer/blocks") + + async def transactions(self) -> List[Dict[str, Any]]: + """Return recent transactions from the explorer. + + Returns: + list: Recent transaction records. + """ + return await self._parent._get("/api/explorer/transactions") diff --git a/sdk/python/tests/test_rustchain_sdk.py b/sdk/python/tests/test_rustchain_sdk.py new file mode 100644 index 00000000..91d02ed9 --- /dev/null +++ b/sdk/python/tests/test_rustchain_sdk.py @@ -0,0 +1,262 @@ +"""Unit tests for the rustchain Python SDK (Issue #2297). + +Covers: +- RustChainClient (health, epoch, miners, balance, transfer, attestation_status) +- ExplorerClient (blocks, transactions) +- Typed exceptions +- CLI arg parsing +- Context-manager lifecycle +- Edge cases (non-positive amount, error status codes) +""" + +from __future__ import annotations + +import sys +import os +import json +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch +import pytest + +# Ensure the repo root is on the path so `rustchain` is importable +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import rustchain +from rustchain.client import RustChainClient +from rustchain.explorer import ExplorerClient +from rustchain.exceptions import ( + RustChainError, + RustChainHTTPError, + RustChainConnectionError, + RustChainTimeoutError, + RustChainNotFoundError, + RustChainAuthError, +) + + +# ──────────────────────────────────────────────────────────────────────────── +# Helpers +# ──────────────────────────────────────────────────────────────────────────── + +def _mock_response(status_code: int, body: Any) -> MagicMock: + """Build a fake httpx.Response.""" + resp = MagicMock() + resp.status_code = status_code + resp.json.return_value = body + resp.text = json.dumps(body) + return resp + + +# ──────────────────────────────────────────────────────────────────────────── +# Exception hierarchy +# ──────────────────────────────────────────────────────────────────────────── + +class TestExceptions: + def test_base_error_is_exception(self): + err = RustChainError("boom") + assert isinstance(err, Exception) + assert str(err) == "boom" + + def test_base_error_stores_status_code(self): + err = RustChainError("boom", status_code=500) + assert err.status_code == 500 + + def test_http_error_inherits_base(self): + assert issubclass(RustChainHTTPError, RustChainError) + + def test_connection_error_inherits_base(self): + assert issubclass(RustChainConnectionError, RustChainError) + + def test_timeout_error_inherits_base(self): + assert issubclass(RustChainTimeoutError, RustChainError) + + def test_not_found_error_has_404(self): + err = RustChainNotFoundError() + assert err.status_code == 404 + + def test_auth_error_has_401(self): + err = RustChainAuthError() + assert err.status_code == 401 + + def test_not_found_inherits_http_error(self): + assert issubclass(RustChainNotFoundError, RustChainHTTPError) + + def test_auth_error_inherits_http_error(self): + assert issubclass(RustChainAuthError, RustChainHTTPError) + + +# ──────────────────────────────────────────────────────────────────────────── +# Client construction +# ──────────────────────────────────────────────────────────────────────────── + +class TestClientConstruction: + def test_default_node_url(self): + client = RustChainClient() + assert "50.28.86.131" in client.node_url + + def test_custom_node_url(self): + client = RustChainClient("https://mynode.example.com") + assert client.node_url == "https://mynode.example.com" + + def test_trailing_slash_stripped(self): + client = RustChainClient("https://mynode.example.com/") + assert not client.node_url.endswith("/") + + def test_explorer_attribute_is_explorer_client(self): + client = RustChainClient() + assert isinstance(client.explorer, ExplorerClient) + + def test_env_var_sets_node_url(self, monkeypatch): + monkeypatch.setenv("RUSTCHAIN_NODE_URL", "https://env-node.example.com") + client = RustChainClient() + assert client.node_url == "https://env-node.example.com" + + +# ──────────────────────────────────────────────────────────────────────────── +# _handle — status-code mapping +# ──────────────────────────────────────────────────────────────────────────── + +class TestHandleResponse: + def test_200_returns_json(self): + resp = _mock_response(200, {"ok": True}) + assert RustChainClient._handle(resp) == {"ok": True} + + def test_404_raises_not_found(self): + resp = _mock_response(404, {}) + with pytest.raises(RustChainNotFoundError): + RustChainClient._handle(resp) + + def test_401_raises_auth_error(self): + resp = _mock_response(401, {}) + with pytest.raises(RustChainAuthError): + RustChainClient._handle(resp) + + def test_403_raises_auth_error(self): + resp = _mock_response(403, {}) + with pytest.raises(RustChainAuthError): + RustChainClient._handle(resp) + + def test_500_raises_http_error(self): + resp = _mock_response(500, {"error": "internal"}) + with pytest.raises(RustChainHTTPError) as exc_info: + RustChainClient._handle(resp) + assert exc_info.value.status_code == 500 + + +# ──────────────────────────────────────────────────────────────────────────── +# Async API methods +# ──────────────────────────────────────────────────────────────────────────── + +@pytest.fixture() +def client(): + return RustChainClient("https://testnode.example.com") + + +@pytest.mark.asyncio +class TestHealthEndpoint: + async def test_health_calls_correct_path(self, client): + client._get = AsyncMock(return_value={"ok": True, "version": "1.0"}) + result = await client.health() + client._get.assert_called_once_with("/health") + assert result["ok"] is True + + +@pytest.mark.asyncio +class TestEpochEndpoint: + async def test_epoch_calls_correct_path(self, client): + payload = {"epoch": 42, "slot": 7} + client._get = AsyncMock(return_value=payload) + result = await client.epoch() + client._get.assert_called_once_with("/epoch") + assert result["epoch"] == 42 + + +@pytest.mark.asyncio +class TestMinersEndpoint: + async def test_miners_calls_correct_path(self, client): + client._get = AsyncMock(return_value=[{"id": "miner-1"}]) + result = await client.miners() + client._get.assert_called_once_with("/api/miners") + assert result[0]["id"] == "miner-1" + + +@pytest.mark.asyncio +class TestBalanceEndpoint: + async def test_balance_passes_wallet_id(self, client): + client._get = AsyncMock(return_value={"balance": 100.0}) + result = await client.balance("my-wallet") + client._get.assert_called_once_with( + "/wallet/balance", params={"miner_id": "my-wallet"} + ) + assert result["balance"] == 100.0 + + +@pytest.mark.asyncio +class TestTransferEndpoint: + async def test_transfer_sends_correct_payload(self, client): + client._post = AsyncMock(return_value={"status": "ok"}) + result = await client.transfer("alice", "bob", 50.0, "sig-abc") + client._post.assert_called_once_with( + "/wallet/transfer", + json={"from": "alice", "to": "bob", "amount": 50.0, "signature": "sig-abc"}, + ) + assert result["status"] == "ok" + + async def test_transfer_raises_on_zero_amount(self, client): + with pytest.raises(ValueError): + await client.transfer("alice", "bob", 0, "sig") + + async def test_transfer_raises_on_negative_amount(self, client): + with pytest.raises(ValueError): + await client.transfer("alice", "bob", -5, "sig") + + +@pytest.mark.asyncio +class TestAttestationEndpoint: + async def test_attestation_status_correct_path(self, client): + client._get = AsyncMock(return_value={"attested": True}) + result = await client.attestation_status("miner-42") + client._get.assert_called_once_with("/api/attestation/miner-42") + assert result["attested"] is True + + +# ──────────────────────────────────────────────────────────────────────────── +# ExplorerClient +# ──────────────────────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +class TestExplorerClient: + async def test_blocks_calls_correct_path(self, client): + client._get = AsyncMock(return_value=[{"height": 1}]) + result = await client.explorer.blocks() + client._get.assert_called_once_with("/api/explorer/blocks") + assert result[0]["height"] == 1 + + async def test_transactions_calls_correct_path(self, client): + client._get = AsyncMock(return_value=[{"tx_id": "abc"}]) + result = await client.explorer.transactions() + client._get.assert_called_once_with("/api/explorer/transactions") + assert result[0]["tx_id"] == "abc" + + +# ──────────────────────────────────────────────────────────────────────────── +# Package-level exports +# ──────────────────────────────────────────────────────────────────────────── + +class TestPackageExports: + def test_client_importable_from_package(self): + assert rustchain.RustChainClient is RustChainClient + + def test_version_is_set(self): + assert rustchain.__version__ + + def test_exceptions_exported(self): + for name in [ + "RustChainError", + "RustChainHTTPError", + "RustChainConnectionError", + "RustChainTimeoutError", + "RustChainNotFoundError", + "RustChainAuthError", + ]: + assert hasattr(rustchain, name)