diff --git a/README.md b/README.md
index 4aecf9844..b13d80149 100644
--- a/README.md
+++ b/README.md
@@ -1,445 +1,233 @@
-
+# rustchain — Python SDK for RustChain
-# 🧱 RustChain: Proof-of-Antiquity Blockchain
+> Python SDK for the [RustChain](https://rustchain.org) Proof-of-Antiquity blockchain.
+> Installable via `pip install rustchain`.
-[](https://github.com/Scottcjn/Rustchain/actions/workflows/ci.yml)
-[](LICENSE)
-[](https://github.com/Scottcjn/Rustchain/stargazers)
-[](https://github.com/Scottcjn/Rustchain/graphs/contributors)
-[](https://github.com/Scottcjn/Rustchain/commits/main)
-[](https://github.com/Scottcjn/Rustchain/issues)
-[](https://github.com/Scottcjn/Rustchain)
-[](https://github.com/Scottcjn/Rustchain)
-[](https://python.org)
-[](https://rustchain.org/explorer)
-[](https://github.com/Scottcjn/rustchain-bounties/issues)
-[](https://bottube.ai)
-[](https://github.com/Scottcjn/Rustchain/discussions)
+[](https://pypi.org/project/rustchain/)
+[](https://pypi.org/project/rustchain/)
-**The first blockchain that rewards vintage hardware for being old, not fast.**
+## What is RustChain?
-*Your PowerPC G4 earns more than a modern Threadripper. That's the point.*
+RustChain is a Proof-of-Antiquity blockchain that rewards vintage and exotic hardware.
+Older architectures (PowerPC G4, SPARC, MIPS, 68K) earn **higher antiquity multipliers**
+than modern x86 — a 2.5x multiplier for G4 hardware, 3.5x for Motorola 68K.
-[Website](https://rustchain.org) • [Live Explorer](https://rustchain.org/explorer) • [Swap wRTC](https://raydium.io/swap/?inputMint=sol&outputMint=12TAdKXxcGf6oCv4rqDz2NkgxjyHq6HQKoxKZYGf5i4X) • [DexScreener](https://dexscreener.com/solana/8CF2Q8nSCxRacDShbtF86XTSrYjueBMKmfdR3MLdnYzb) • [wRTC Quickstart](docs/wrtc.md) • [wRTC Tutorial](docs/WRTC_ONBOARDING_TUTORIAL.md) • [Grokipedia Ref](https://grokipedia.com/search?q=RustChain) • [Whitepaper](docs/RustChain_Whitepaper_Flameholder_v0.97-1.pdf) • [Quick Start](#-quick-start) • [How It Works](#-how-proof-of-antiquity-works)
-
-
-
----
-
-## 🪙 wRTC on Solana
-
-RustChain Token (RTC) is now available as **wRTC** on Solana via the BoTTube Bridge:
-
-| Resource | Link |
-|----------|------|
-| **Swap wRTC** | [Raydium DEX](https://raydium.io/swap/?inputMint=sol&outputMint=12TAdKXxcGf6oCv4rqDz2NkgxjyHq6HQKoxKZYGf5i4X) |
-| **Price Chart** | [DexScreener](https://dexscreener.com/solana/8CF2Q8nSCxRacDShbtF86XTSrYjueBMKmfdR3MLdnYzb) |
-| **Bridge RTC ↔ wRTC** | [BoTTube Bridge](https://bottube.ai/bridge) |
-| **Quickstart Guide** | [wRTC Quickstart (Buy, Bridge, Safety)](docs/wrtc.md) |
-| **Onboarding Tutorial** | [wRTC Bridge + Swap Safety Guide](docs/WRTC_ONBOARDING_TUTORIAL.md) |
-| **External Reference** | [Grokipedia Search: RustChain](https://grokipedia.com/search?q=RustChain) |
-| **Token Mint** | `12TAdKXxcGf6oCv4rqDz2NkgxjyHq6HQKoxKZYGf5i4X` |
-
----
-
-## Contribute & Earn RTC
-
-Every contribution earns RTC tokens. Bug fixes, features, docs, security audits — all paid.
-
-| Tier | Reward | Examples |
-|------|--------|----------|
-| Micro | 1-10 RTC | Typo fix, small docs, simple test |
-| Standard | 20-50 RTC | Feature, refactor, new endpoint |
-| Major | 75-100 RTC | Security fix, consensus improvement |
-| Critical | 100-150 RTC | Vulnerability patch, protocol upgrade |
-
-**Get started:**
-1. Browse [open bounties](https://github.com/Scottcjn/rustchain-bounties/issues)
-2. Pick a [good first issue](https://github.com/Scottcjn/Rustchain/labels/good%20first%20issue) (5-10 RTC)
-3. Fork, fix, PR — get paid in RTC
-4. See [CONTRIBUTING.md](CONTRIBUTING.md) for full details
-
-**1 RTC = $0.10 USD** | `pip install clawrtc` to start mining
-
----
-
-## Agent Wallets + x402 Payments
-
-RustChain agents can now own **Coinbase Base wallets** and make machine-to-machine payments using the **x402 protocol** (HTTP 402 Payment Required):
-
-| Resource | Link |
-|----------|------|
-| **Agent Wallets Docs** | [rustchain.org/wallets.html](https://rustchain.org/wallets.html) |
-| **wRTC on Base** | [`0x5683C10596AaA09AD7F4eF13CAB94b9b74A669c6`](https://basescan.org/address/0x5683C10596AaA09AD7F4eF13CAB94b9b74A669c6) |
-| **Swap USDC to wRTC** | [Aerodrome DEX](https://aerodrome.finance/swap?from=0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913&to=0x5683C10596AaA09AD7F4eF13CAB94b9b74A669c6) |
-| **Base Bridge** | [bottube.ai/bridge/base](https://bottube.ai/bridge/base) |
+## Install
```bash
-# Create a Coinbase wallet
-pip install clawrtc[coinbase]
-clawrtc wallet coinbase create
-
-# Check swap info
-clawrtc wallet coinbase swap-info
-
-# Link existing Base address
-clawrtc wallet coinbase link 0xYourBaseAddress
+pip install rustchain
```
-**x402 Premium API endpoints** are live (currently free while proving the flow):
-- `GET /api/premium/videos` - Bulk video export (BoTTube)
-- `GET /api/premium/analytics/` - Deep agent analytics (BoTTube)
-- `GET /api/premium/reputation` - Full reputation export (Beacon Atlas)
-- `GET /wallet/swap-info` - USDC/wRTC swap guidance (RustChain)
-
-## 📄 Academic Publications
-
-| Paper | DOI | Topic |
-|-------|-----|-------|
-| **RustChain: One CPU, One Vote** | [](https://doi.org/10.5281/zenodo.18623592) | Proof of Antiquity consensus, hardware fingerprinting |
-| **Non-Bijunctive Permutation Collapse** | [](https://doi.org/10.5281/zenodo.18623920) | AltiVec vec_perm for LLM attention (27-96x advantage) |
-| **PSE Hardware Entropy** | [](https://doi.org/10.5281/zenodo.18623922) | POWER8 mftb entropy for behavioral divergence |
-| **Neuromorphic Prompt Translation** | [](https://doi.org/10.5281/zenodo.18623594) | Emotional prompting for 20% video diffusion gains |
-| **RAM Coffers** | [](https://doi.org/10.5281/zenodo.18321905) | NUMA-distributed weight banking for LLM inference |
-
----
-
-## 🎯 What Makes RustChain Different
-
-| Traditional PoW | Proof-of-Antiquity |
-|----------------|-------------------|
-| Rewards fastest hardware | Rewards oldest hardware |
-| Newer = Better | Older = Better |
-| Wasteful energy consumption | Preserves computing history |
-| Race to the bottom | Rewards digital preservation |
-
-**Core Principle**: Authentic vintage hardware that has survived decades deserves recognition. RustChain flips mining upside-down.
-
-## ⚡ Quick Start
-
-### One-Line Install (Recommended)
-```bash
-curl -sSL https://raw.githubusercontent.com/Scottcjn/Rustchain/main/install-miner.sh | bash
-```
-
-The installer:
-- ✅ Auto-detects your platform (Linux/macOS, x86_64/ARM/PowerPC)
-- ✅ Creates an isolated Python virtualenv (no system pollution)
-- ✅ Downloads the correct miner for your hardware
-- ✅ Sets up auto-start on boot (systemd/launchd)
-- ✅ Provides easy uninstall
-
-### Installation with Options
-
-**Install with a specific wallet:**
-```bash
-curl -sSL https://raw.githubusercontent.com/Scottcjn/Rustchain/main/install-miner.sh | bash -s -- --wallet my-miner-wallet
-```
-
-**Uninstall:**
-```bash
-curl -sSL https://raw.githubusercontent.com/Scottcjn/Rustchain/main/install-miner.sh | bash -s -- --uninstall
-```
-
-### Supported Platforms
-- ✅ Ubuntu 20.04+, Debian 11+, Fedora 38+ (x86_64, ppc64le)
-- ✅ macOS 12+ (Intel, Apple Silicon, PowerPC)
-- ✅ IBM POWER8 systems
-
-### Troubleshooting
-
-- **Installer fails with permission errors**: re-run using an account with write access to `~/.local` and avoid running inside a system Python's global site-packages.
-- **Python version errors** (`SyntaxError` / `ModuleNotFoundError`): install with Python 3.10+ and set `python3` to that interpreter.
- ```bash
- python3 --version
- curl -sSL https://raw.githubusercontent.com/Scottcjn/Rustchain/main/install-miner.sh | bash
- ```
-- **HTTPS certificate errors in `curl`**: this can happen with non-browser client environments; check connectivity first with `curl -I https://rustchain.org` before wallet checks.
-- **Miner exits immediately**: verify wallet exists and service is running (`systemctl --user status rustchain-miner` or `launchctl list | grep rustchain`)
-
-If an issue persists, include logs and OS details in a new issue or bounty comment with exact error output and your `install-miner.sh --dry-run` result.
+For async features (`AsyncRustChainClient`):
-### After Installation
-
-**Check your wallet balance:**
```bash
-# Note: Using -sk flags because the node may use a self-signed SSL certificate
-curl -sk "https://50.28.86.131/wallet/balance?miner_id=YOUR_WALLET_NAME"
+pip install rustchain[aiohttp]
```
-**List active miners:**
-```bash
-curl -sk https://50.28.86.131/api/miners
-```
-
-**Check node health:**
-```bash
-curl -sk https://50.28.86.131/health
-```
+For Ed25519 signing support:
-**Get current epoch:**
```bash
-curl -sk https://50.28.86.131/epoch
+pip install rustchain[crypto] # uses cryptography.io
+# or
+pip install rustchain[ed25519] # uses ed25519-blake2b
```
-**Manage the miner service:**
+For development:
-*Linux (systemd):*
```bash
-systemctl --user status rustchain-miner # Check status
-systemctl --user stop rustchain-miner # Stop mining
-systemctl --user start rustchain-miner # Start mining
-journalctl --user -u rustchain-miner -f # View logs
+pip install rustchain[dev]
+pytest tests/
```
-*macOS (launchd):*
-```bash
-launchctl list | grep rustchain # Check status
-launchctl stop com.rustchain.miner # Stop mining
-launchctl start com.rustchain.miner # Start mining
-tail -f ~/.rustchain/miner.log # View logs
-```
+## Quickstart
-### Manual Install
-```bash
-git clone https://github.com/Scottcjn/Rustchain.git
-cd Rustchain
-bash install-miner.sh --wallet YOUR_WALLET_NAME
-# Optional: preview actions without changing your system
-bash install-miner.sh --dry-run --wallet YOUR_WALLET_NAME
-```
+### Sync Client
-## 💰 Bounty Board
+```python
+from rustchain import RustChainClient
-Earn **RTC** by contributing to the RustChain ecosystem!
+client = RustChainClient()
-| Bounty | Reward | Link |
-|--------|--------|------|
-| **First Real Contribution** | 10 RTC | [#48](https://github.com/Scottcjn/Rustchain/issues/48) |
-| **Network Status Page** | 25 RTC | [#161](https://github.com/Scottcjn/Rustchain/issues/161) |
-| **AI Agent Hunter** | 200 RTC | [Agent Bounty #34](https://github.com/Scottcjn/rustchain-bounties/issues/34) |
+# Node health
+health = client.health()
+print(f"Node {health['version']} — uptime: {health['uptime_s']}s")
----
+# Current epoch
+epoch = client.epoch()
+print(f"Epoch {epoch['epoch']}, slot {epoch['slot']}")
-## 💰 Antiquity Multipliers
+# Active miners
+miners = client.miners()
+for m in miners[:5]:
+ print(f" {m['miner']} — antiquity ×{m['antiquity_multiplier']}")
-Your hardware's age determines your mining rewards:
+# Wallet balance
+balance = client.balance("Ivan-houzhiwen")
+print(f"Balance: {balance['amount_rtc']} RTC")
-| Hardware | Era | Multiplier | Example Earnings |
-|----------|-----|------------|------------------|
-| **PowerPC G4** | 1999-2005 | **2.5×** | 0.30 RTC/epoch |
-| **PowerPC G5** | 2003-2006 | **2.0×** | 0.24 RTC/epoch |
-| **PowerPC G3** | 1997-2003 | **1.8×** | 0.21 RTC/epoch |
-| **IBM POWER8** | 2014 | **1.5×** | 0.18 RTC/epoch |
-| **Pentium 4** | 2000-2008 | **1.5×** | 0.18 RTC/epoch |
-| **Core 2 Duo** | 2006-2011 | **1.3×** | 0.16 RTC/epoch |
-| **Apple Silicon** | 2020+ | **1.2×** | 0.14 RTC/epoch |
-| **Modern x86_64** | Current | **1.0×** | 0.12 RTC/epoch |
+# Attestation status
+status = client.attestation_status("g4-powerbook-001")
+print(f"Verified: {status['verified']}, score: {status['antiquity_score']}")
+```
-*Multipliers decay over time (15%/year) to prevent permanent advantage.*
+### Explorer — Blocks & Transactions
-## 🔧 How Proof-of-Antiquity Works
+```python
+# Recent blocks
+blocks = client.explorer.blocks(limit=20)
+for b in blocks["blocks"]:
+ print(f" Block {b['height']} — hash {b['hash'][:16]}...")
-### 1. Hardware Fingerprinting (RIP-PoA)
+# Recent transactions
+txs = client.explorer.transactions(limit=50)
+for tx in txs["transactions"]:
+ print(f" {tx['hash'][:16]}... {tx['from']} → {tx['to']} : {tx['amount']}")
-Every miner must prove their hardware is real, not emulated:
+# Transactions for a specific wallet
+wallet_txs = client.explorer.transactions(wallet_id="my-wallet", limit=100)
+# Single block by height or hash
+block = client.explorer.block_by_height(1234)
+tx = client.explorer.transaction_by_hash("0xdeadbeef...")
```
-┌─────────────────────────────────────────────────────────────┐
-│ 6 Hardware Checks │
-├─────────────────────────────────────────────────────────────┤
-│ 1. Clock-Skew & Oscillator Drift ← Silicon aging pattern │
-│ 2. Cache Timing Fingerprint ← L1/L2/L3 latency tone │
-│ 3. SIMD Unit Identity ← AltiVec/SSE/NEON bias │
-│ 4. Thermal Drift Entropy ← Heat curves are unique │
-│ 5. Instruction Path Jitter ← Microarch jitter map │
-│ 6. Anti-Emulation Checks ← Detect VMs/emulators │
-└─────────────────────────────────────────────────────────────┘
-```
-
-**Why it matters**: A SheepShaver VM pretending to be a G4 Mac will fail these checks. Real vintage silicon has unique aging patterns that can't be faked.
-
-### 2. 1 CPU = 1 Vote (RIP-200)
-
-Unlike PoW where hash power = votes, RustChain uses **round-robin consensus**:
-- Each unique hardware device gets exactly 1 vote per epoch
-- Rewards split equally among all voters, then multiplied by antiquity
-- No advantage from running multiple threads or faster CPUs
+### Signed Transfer
-### 3. Epoch-Based Rewards
+```python
+from rustchain import RustChainClient
+from rustchain.crypto import SigningKey
-```
-Epoch Duration: 10 minutes (600 seconds)
-Base Reward Pool: 1.5 RTC per epoch
-Distribution: Equal split × antiquity multiplier
-```
+client = RustChainClient()
+key = SigningKey.generate() # generate a key
+# or: key = SigningKey.from_seed(b"my BIP39 seed phrase")
-**Example with 5 miners:**
-```
-G4 Mac (2.5×): 0.30 RTC ████████████████████
-G5 Mac (2.0×): 0.24 RTC ████████████████
-Modern PC (1.0×): 0.12 RTC ████████
-Modern PC (1.0×): 0.12 RTC ████████
-Modern PC (1.0×): 0.12 RTC ████████
- ─────────
-Total: 0.90 RTC (+ 0.60 RTC returned to pool)
+# Transfer 1 RTC (1_000_000 smallest units) from alice to bob
+result = client.transfer_signed(
+ from_wallet="alice",
+ to_wallet="bob",
+ amount=1_000_000,
+ signing_key=key,
+ fee=1000,
+)
+print(result)
```
-## 🌐 Network Architecture
-
-### Live Nodes (3 Active)
-
-| Node | Location | Role | Status |
-|------|----------|------|--------|
-| **Node 1** | 50.28.86.131 | Primary + Explorer | ✅ Active |
-| **Node 2** | 50.28.86.153 | Ergo Anchor | ✅ Active |
-| **Node 3** | 76.8.228.245 | External (Community) | ✅ Active |
+### Async Client
-### Ergo Blockchain Anchoring
+```python
+import asyncio
+from rustchain import AsyncRustChainClient
-RustChain periodically anchors to the Ergo blockchain for immutability:
+async def main():
+ client = AsyncRustChainClient()
+ health, epoch, miners = await asyncio.gather(
+ client.health(),
+ client.epoch(),
+ client.miners(),
+ )
+ print(f"Epoch {epoch['epoch']}: {len(miners)} miners online")
+asyncio.run(main())
```
-RustChain Epoch → Commitment Hash → Ergo Transaction (R4 register)
-```
-
-This provides cryptographic proof that RustChain state existed at a specific time.
-## 📊 API Endpoints
+## CLI
```bash
-# Check network health
-curl -sk https://50.28.86.131/health
+# Check node health
+rustchain health
+
+# Get wallet balance
+rustchain balance Ivan-houzhiwen
# Get current epoch
-curl -sk https://50.28.86.131/epoch
+rustchain epoch
# List active miners
-curl -sk https://50.28.86.131/api/miners
-
-# Check wallet balance
-curl -sk "https://50.28.86.131/wallet/balance?miner_id=YOUR_WALLET"
-
-# Block explorer (web browser)
-open https://rustchain.org/explorer
-```
-
-## 🖥️ Supported Platforms
-
-| Platform | Architecture | Status | Notes |
-|----------|--------------|--------|-------|
-| **Mac OS X Tiger** | PowerPC G4/G5 | ✅ Full Support | Python 2.5 compatible miner |
-| **Mac OS X Leopard** | PowerPC G4/G5 | ✅ Full Support | Recommended for vintage Macs |
-| **Ubuntu Linux** | ppc64le/POWER8 | ✅ Full Support | Best performance |
-| **Ubuntu Linux** | x86_64 | ✅ Full Support | Standard miner |
-| **macOS Sonoma** | Apple Silicon | ✅ Full Support | M1/M2/M3 chips |
-| **Windows 10/11** | x86_64 | ✅ Full Support | Python 3.8+ |
-| **DOS** | 8086/286/386 | 🔧 Experimental | Badge rewards only |
-
-## 🏅 NFT Badge System
-
-Earn commemorative badges for mining milestones:
+rustchain miners
-| Badge | Requirement | Rarity |
-|-------|-------------|--------|
-| 🔥 **Bondi G3 Flamekeeper** | Mine on PowerPC G3 | Rare |
-| ⚡ **QuickBasic Listener** | Mine from DOS machine | Legendary |
-| 🛠️ **DOS WiFi Alchemist** | Network DOS machine | Mythic |
-| 🏛️ **Pantheon Pioneer** | First 100 miners | Limited |
+# Generate a new wallet
+rustchain wallet generate
+rustchain wallet generate --seed "my secret seed phrase"
-## 🔒 Security Model
+# Sign a transfer payload
+rustchain wallet sign alice bob 1000000 --fee 1000 --seed "seed"
-### Anti-VM Detection
-VMs are detected and receive **1 billionth** of normal rewards:
-```
-Real G4 Mac: 2.5× multiplier = 0.30 RTC/epoch
-Emulated G4: 0.0000000025× = 0.0000000003 RTC/epoch
+# Submit a signed transfer
+rustchain transfer alice bob 1000000 --fee 1000 --sig
```
-### Hardware Binding
-Each hardware fingerprint is bound to one wallet. Prevents:
-- Multiple wallets on same hardware
-- Hardware spoofing
-- Sybil attacks
+## API Reference
-## 📁 Repository Structure
+### `RustChainClient`
-```
-Rustchain/
-├── install-miner.sh # Universal miner installer (Linux/macOS)
-├── node/
-│ ├── rustchain_v2_integrated_v2.2.1_rip200.py # Full node implementation
-│ └── fingerprint_checks.py # Hardware verification
-├── miners/
-│ ├── linux/rustchain_linux_miner.py # Linux miner
-│ └── macos/rustchain_mac_miner_v2.4.py # macOS miner
-├── docs/
-│ ├── RustChain_Whitepaper_*.pdf # Technical whitepaper
-│ └── chain_architecture.md # Architecture docs
-├── tools/
-│ └── validator_core.py # Block validation
-└── nfts/ # Badge definitions
-```
+| Method | Returns | Description |
+|--------|---------|-------------|
+| `client.health()` | `dict` | Node health & version |
+| `client.epoch()` | `dict` | Current epoch info |
+| `client.miners()` | `list[dict]` | All active miners |
+| `client.balance(wallet_id)` | `dict` | RTC balance for wallet |
+| `client.transfer(from, to, amount, signature, ...)` | `dict` | Submit signed transfer |
+| `client.transfer_signed(from, to, amount, signing_key, ...)` | `dict` | Sign & submit in one call |
+| `client.attestation_status(miner_id)` | `dict` | Attestation verification status |
-## ✅ Beacon Certified Open Source (BCOS)
+### `Explorer`
-RustChain accepts AI-assisted PRs, but we require *evidence* and *review* so maintainers don't drown in low-quality code generation.
+| Method | Returns | Description |
+|--------|---------|-------------|
+| `client.explorer.blocks(limit=20)` | `dict` | Recent blocks |
+| `client.explorer.block_by_height(n)` | `dict` | Single block |
+| `client.explorer.block_by_hash(hash)` | `dict` | Single block |
+| `client.explorer.transactions(limit=50)` | `dict` | Recent transactions |
+| `client.explorer.transaction_by_hash(hash)` | `dict` | Single transaction |
-Read the draft spec:
-- `docs/BEACON_CERTIFIED_OPEN_SOURCE.md`
+### Exceptions
-## 🔗 Related Projects & Links
+All exceptions inherit from `RustChainError`:
-| Resource | Link |
-|---------|------|
-| **Website** | [rustchain.org](https://rustchain.org) |
-| **Block Explorer** | [rustchain.org/explorer](https://rustchain.org/explorer) |
-| **Swap wRTC (Raydium)** | [Raydium DEX](https://raydium.io/swap/?inputMint=sol&outputMint=12TAdKXxcGf6oCv4rqDz2NkgxjyHq6HQKoxKZYGf5i4X) |
-| **Price Chart** | [DexScreener](https://dexscreener.com/solana/8CF2Q8nSCxRacDShbtF86XTSrYjueBMKmfdR3MLdnYzb) |
-| **Bridge RTC ↔ wRTC** | [BoTTube Bridge](https://bottube.ai/bridge) |
-| **wRTC Token Mint** | `12TAdKXxcGf6oCv4rqDz2NkgxjyHq6HQKoxKZYGf5i4X` |
-| **BoTTube** | [bottube.ai](https://bottube.ai) - AI video platform |
-| **Moltbook** | [moltbook.com](https://moltbook.com) - AI social network |
-| [nvidia-power8-patches](https://github.com/Scottcjn/nvidia-power8-patches) | NVIDIA drivers for POWER8 |
-| [llama-cpp-power8](https://github.com/Scottcjn/llama-cpp-power8) | LLM inference on POWER8 |
-| [ppc-compilers](https://github.com/Scottcjn/ppc-compilers) | Modern compilers for vintage Macs |
+- `APIError` — non-2xx response (has `.status_code`)
+- `ConnectionError` — cannot reach node
+- `TimeoutError` — request timed out
+- `ValidationError` — bad input (empty wallet ID, negative amount)
+- `WalletError` — wallet operation failed
+- `SigningError` — Ed25519 signing failed
-## 📝 Articles
+## WebSocket Feed (Real-time)
-- [Proof of Antiquity: A Blockchain That Rewards Vintage Hardware](https://dev.to/scottcjn/proof-of-antiquity-a-blockchain-that-rewards-vintage-hardware-4ii3) - Dev.to
-- [I Run LLMs on a 768GB IBM POWER8 Server](https://dev.to/scottcjn/i-run-llms-on-a-768gb-ibm-power8-server-and-its-faster-than-you-think-1o) - Dev.to
+For real-time block updates via WebSocket:
-## 🙏 Attribution
+```python
+import asyncio
+from rustchain.websocket_feed import BlockFeed
-**A year of development, real vintage hardware, electricity bills, and a dedicated lab went into this.**
+async def on_block(block):
+ print(f"New block: {block['height']}")
-If you use RustChain:
-- ⭐ **Star this repo** - Helps others find it
-- 📝 **Credit in your project** - Keep the attribution
-- 🔗 **Link back** - Share the love
-
-```
-RustChain - Proof of Antiquity by Scott (Scottcjn)
-https://github.com/Scottcjn/Rustchain
+feed = BlockFeed()
+asyncio.run(feed.subscribe(on_block))
```
-## 📜 License
+## Node Endpoints
-MIT License - Free to use, but please keep the copyright notice and attribution.
+Default node: `https://50.28.86.131`
----
+| Endpoint | Method | Description |
+|----------|--------|-------------|
+| `/health` | GET | Node health check |
+| `/epoch` | GET | Current epoch info |
+| `/api/miners` | GET | Active miners list |
+| `/wallet/balance` | GET | Wallet balance |
+| `/wallet/transfer/signed` | POST | Signed transfer |
+| `/attest/status/` | GET | Attestation status |
+| `/blocks` | GET | Recent blocks |
+| `/api/transactions` | GET | Recent transactions |
-
+## Testing
-**Made with ⚡ by [Elyan Labs](https://elyanlabs.ai)**
-
-*"Your vintage hardware earns rewards. Make mining meaningful again."*
-
-**DOS boxes, PowerPC G4s, Win95 machines - they all have value. RustChain proves it.**
+```bash
+pip install rustchain[dev]
+pytest tests/ -v
+```
-
+## License
-## Mining Status
-
-
+MIT
diff --git a/pyproject.toml b/pyproject.toml
index a52a05158..fd806c58d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,17 +1,54 @@
-[tool.pytest.ini_options]
-testpaths = ["tests"]
-pythonpath = ["node", "."]
+[build-system]
+requires = ["setuptools>=61.0", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "rustchain"
+version = "0.2.0"
+description = "Python SDK for RustChain blockchain network — Proof of Antiquity consensus"
+readme = "README.md"
+license = {text = "MIT"}
+authors = [
+ {name = "OpenClaw Agent", email = "agent@openclaw.dev"}
+]
+keywords = ["rustchain", "blockchain", "proof-of-antiquity", "crypto", "rtc", "mining"]
+classifiers = [
+ "Development Status :: 4 - Beta",
+ "Intended Audience :: Developers",
+ "License :: OSI Approved :: MIT License",
+ "Operating System :: OS Independent",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.9",
+ "Programming Language :: Python :: 3.10",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Topic :: Internet :: WWW/HTTP :: HTTP Clients",
+ "Topic :: Software Development :: Libraries :: Python Modules",
+]
+requires-python = ">=3.9"
+dependencies = [
+ "aiohttp>=3.9.0",
+ "httpx>=0.27.0",
+]
+
+[project.optional-dependencies]
+dev = [
+ "pytest>=8.0.0",
+ "pytest-asyncio>=0.23.0",
+ "mypy>=1.8.0",
+]
-[tool.ruff]
-line-length = 120
-select = ["E", "F", "W", "B", "I"]
-ignore = []
-exclude = ["deprecated", "node_backups"]
+[project.scripts]
+rustchain = "rustchain.cli:main"
-[tool.ruff.lint]
-ignore = ["E501"] # Ignore long lines for legacy code
+[project.urls]
+Homepage = "https://rustchain.org"
+Repository = "https://github.com/Scottcjn/Rustchain"
+Documentation = "https://github.com/Scottcjn/Rustchain/blob/main/API_WALKTHROUGH.md"
-[tool.mypy]
-python_version = "3.11"
-ignore_missing_imports = true
-exclude = ["deprecated", "node_backups"]
+[tool.setuptools.packages.find]
+where = ["src"]
+
+[tool.pytest.ini_options]
+asyncio_mode = "auto"
+testpaths = ["tests"]
diff --git a/src/rustchain/__init__.py b/src/rustchain/__init__.py
new file mode 100644
index 000000000..74bebc14f
--- /dev/null
+++ b/src/rustchain/__init__.py
@@ -0,0 +1,67 @@
+"""
+rustchain — Python SDK for RustChain Proof-of-Antiquity blockchain.
+
+Install:
+ pip install rustchain
+
+Quickstart:
+ from rustchain import RustChainClient
+
+ client = RustChainClient()
+ health = client.health()
+ epoch = client.epoch()
+ miners = client.miners()
+ balance = client.balance("my-wallet")
+ txs = client.explorer.transactions(limit=50)
+ blocks = client.explorer.blocks(limit=20)
+
+Async usage:
+ from rustchain import AsyncRustChainClient
+ import asyncio
+
+ async def main():
+ client = AsyncRustChainClient()
+ health = await client.health()
+
+ asyncio.run(main())
+
+CLI:
+ rustchain health
+ rustchain balance my-wallet
+ rustchain epoch
+ rustchain miners
+ rustchain wallet generate
+"""
+
+__version__ = "0.2.0"
+
+from .client import RustChainClient, AsyncRustChainClient
+from .explorer import Explorer
+from .crypto import SigningKey
+from .exceptions import (
+ RustChainError,
+ APIError,
+ ConnectionError,
+ TimeoutError,
+ ValidationError,
+ WalletError,
+ SigningError,
+ AttestationError,
+)
+
+__all__ = [
+ # Client
+ "RustChainClient",
+ "AsyncRustChainClient",
+ "Explorer",
+ "SigningKey",
+ # Exceptions
+ "RustChainError",
+ "APIError",
+ "ConnectionError",
+ "TimeoutError",
+ "ValidationError",
+ "WalletError",
+ "SigningError",
+ "AttestationError",
+]
diff --git a/src/rustchain/cli.py b/src/rustchain/cli.py
new file mode 100644
index 000000000..1879a33c2
--- /dev/null
+++ b/src/rustchain/cli.py
@@ -0,0 +1,104 @@
+"""
+RustChain CLI
+Command-line interface for RustChain network.
+
+Usage:
+ rustchain balance
+ rustchain health
+ rustchain epoch
+ rustchain miners
+ rustchain transfer [--fee N] [--seed ]
+ rustchain wallet generate
+ rustchain wallet sign
+"""
+
+import argparse
+import sys
+from . import RustChainClient, AsyncRustChainClient
+from .crypto import SigningKey
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(prog="rustchain", description="RustChain CLI")
+ sub = parser.add_subparsers(dest="cmd", required=True)
+
+ # rustchain health
+ sub.add_parser("health", help="Check node health")
+
+ # rustchain epoch
+ sub.add_parser("epoch", help="Get current epoch info")
+
+ # rustchain miners
+ sub.add_parser("miners", help="List active miners")
+
+ # rustchain balance
+ bal = sub.add_parser("balance", help="Check wallet balance")
+ bal.add_argument("wallet_id", help="Wallet or miner ID")
+
+ # rustchain transfer [--fee] [--sig]
+ xfer = sub.add_parser("transfer", help="Submit a signed transfer")
+ xfer.add_argument("from_wallet")
+ xfer.add_argument("to_wallet")
+ xfer.add_argument("amount", type=int, help="Amount in smallest units (1 RTC = 1_000_000)")
+ xfer.add_argument("--fee", type=int, default=0)
+ xfer.add_argument("--sig", required=True, help="Hex Ed25519 signature")
+
+ # rustchain wallet generate
+ wgen = sub.add_parser("wallet", help="Wallet subcommands")
+ wsub = wgen.add_subparsers(dest="wallet_cmd")
+
+ gen = wsub.add_parser("generate", help="Generate a new Ed25519 wallet")
+ gen.add_argument("--seed", help="Optional seed phrase or hex seed")
+
+ sign = wsub.add_parser("sign", help="Sign a transfer payload")
+ sign.add_argument("from_wallet")
+ sign.add_argument("to_wallet")
+ sign.add_argument("amount", type=int)
+ sign.add_argument("--fee", type=int, default=0)
+ sign.add_argument("--seed", help="Seed to derive key from")
+
+ args = parser.parse_args()
+ client = RustChainClient()
+
+ try:
+ if args.cmd == "health":
+ print(client.health())
+ elif args.cmd == "epoch":
+ print(client.epoch())
+ elif args.cmd == "miners":
+ for m in client.miners():
+ print(m)
+ elif args.cmd == "balance":
+ print(client.balance(args.wallet_id))
+ elif args.cmd == "transfer":
+ print(client.transfer(
+ args.from_wallet, args.to_wallet, args.amount,
+ signature=args.sig, fee=args.fee,
+ ))
+ elif args.wallet_cmd == "generate":
+ if args.seed:
+ key = SigningKey.from_seed(args.seed.encode())
+ else:
+ key = SigningKey.generate()
+ sig = key.sign(b"rustchain-wallet-generated").hex()
+ print(f"Private key (hex): {sig[:64]}...")
+ print("(Store this securely — it cannot be recovered)")
+ elif args.wallet_cmd == "sign":
+ if args.seed:
+ key = SigningKey.from_seed(args.seed.encode())
+ else:
+ key = SigningKey.generate()
+ sig_hex, payload = key.sign_transfer(
+ args.from_wallet, args.to_wallet, args.amount, args.fee,
+ )
+ print(f"Signature: {sig_hex}")
+ print(f"Payload: {payload}")
+ else:
+ parser.print_help()
+ except Exception as e:
+ print(f"Error: {e}", file=sys.stderr)
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/rustchain/client.py b/src/rustchain/client.py
new file mode 100644
index 000000000..e8155bf7a
--- /dev/null
+++ b/src/rustchain/client.py
@@ -0,0 +1,444 @@
+"""
+RustChain Python SDK
+Async-first API client for the RustChain Proof-of-Antiquity blockchain network.
+
+Usage:
+ # Sync
+ from rustchain import RustChainClient
+ client = RustChainClient()
+ print(client.health())
+
+ # Async
+ from rustchain import AsyncRustChainClient
+ async def main():
+ client = AsyncRustChainClient()
+ health = await client.health()
+"""
+
+from __future__ import annotations
+
+import json
+import ssl
+import time
+import urllib.error
+import urllib.request
+from typing import Any, Dict, List, Optional, TYPE_CHECKING
+
+import aiohttp
+
+from .exceptions import APIError, ConnectionError, SigningError, TimeoutError, ValidationError
+from .explorer import Explorer
+from .crypto import SigningKey
+
+if TYPE_CHECKING:
+ from .crypto import SigningKey
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Exceptions (re-export for convenience)
+# ─────────────────────────────────────────────────────────────────────────────
+__all__ = [
+ "RustChainError",
+ "APIError",
+ "ConnectionError",
+ "TimeoutError",
+ "ValidationError",
+ "WalletError",
+ "SigningError",
+ "AttestationError",
+]
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Sync Client
+# ─────────────────────────────────────────────────────────────────────────────
+
+
+class RustChainClient:
+ """
+ Synchronous RustChain API client.
+
+ Attributes:
+ explorer: Explorer accessor for block/transaction data.
+ e.g. ``client.explorer.blocks()``, ``client.explorer.transactions()``
+
+ Example:
+ >>> client = RustChainClient()
+ >>> health = client.health()
+ >>> print(health["version"])
+ 2.2.1-rip200
+
+ >>> balance = client.balance("my-wallet")
+ >>> print(balance["amount_rtc"])
+ 42.0
+
+ >>> status = client.attestation_status("my-miner")
+ >>> print(status["verified"])
+ True
+ """
+
+ DEFAULT_BASE_URL = "https://50.28.86.131"
+
+ def __init__(
+ self,
+ base_url: str = DEFAULT_BASE_URL,
+ *,
+ timeout: int = 30,
+ verify_ssl: bool = False,
+ retry_count: int = 3,
+ retry_delay: float = 1.0,
+ ) -> None:
+ self.base_url = base_url.rstrip("/")
+ self.timeout = timeout
+ self.verify_ssl = verify_ssl
+ self.retry_count = retry_count
+ self.retry_delay = retry_delay
+
+ self._ctx: Optional[ssl.SSLContext] = None
+ if not verify_ssl:
+ self._ctx = ssl.create_default_context()
+ self._ctx.check_hostname = False
+ self._ctx.verify_mode = ssl.CERT_NONE
+
+ self.explorer = Explorer(
+ base_url=self.base_url,
+ timeout=self.timeout,
+ verify_ssl=self.verify_ssl,
+ )
+
+ # ── HTTP helpers ──────────────────────────────────────────────────────────
+
+ def _urlopen(self, method: str, path: str, data: Optional[bytes] = None) -> Dict[str, Any]:
+ """Make an HTTP request with retry logic."""
+ import urllib.error
+
+ url = f"{self.base_url}{path}"
+ for attempt in range(self.retry_count):
+ try:
+ req = urllib.request.Request(
+ url,
+ data=data,
+ headers={
+ "Content-Type": "application/json",
+ "Accept": "application/json",
+ },
+ method=method,
+ )
+ with urllib.request.urlopen(req, context=self._ctx, timeout=self.timeout) as resp:
+ return json.loads(resp.read())
+ except urllib.error.HTTPError as e:
+ if attempt == self.retry_count - 1:
+ body = e.read().decode(errors="replace")
+ raise APIError(
+ f"HTTP {e.code} on {path}: {body}",
+ status_code=e.code,
+ details={"path": path, "attempt": attempt + 1},
+ )
+ except TimeoutError:
+ raise TimeoutError(f"Request timed out after {self.timeout}s")
+ except Exception as e:
+ if attempt == self.retry_count - 1:
+ raise ConnectionError(f"Connection failed: {e}", details={"path": path})
+
+ if attempt < self.retry_count - 1:
+ time.sleep(self.retry_delay * (attempt + 1))
+
+ raise ConnectionError("Max retries exceeded")
+
+ def _get(self, path: str) -> Dict[str, Any]:
+ return self._urlopen("GET", path)
+
+ def _post(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]:
+ body = json.dumps(payload).encode()
+ return self._urlopen("POST", path, data=body)
+
+ # ── Node API ──────────────────────────────────────────────────────────────
+
+ def health(self) -> Dict[str, Any]:
+ """
+ Check node health and version info.
+
+ Returns:
+ Dict with keys: ok, version, uptime_s, db_rw, etc.
+
+ Example:
+ >>> client.health()
+ {'ok': True, 'version': '2.2.1-rip200', 'uptime_s': 140828}
+ """
+ return self._get("/health")
+
+ def epoch(self) -> Dict[str, Any]:
+ """
+ Get current epoch information.
+
+ Returns:
+ Dict with keys: epoch, slot, height, blocks_per_epoch, epoch_pot.
+
+ Example:
+ >>> client.epoch()
+ {'epoch': 95, 'slot': 12345, 'height': 67890}
+ """
+ return self._get("/epoch")
+
+ def miners(self) -> List[Dict[str, Any]]:
+ """
+ List all active miners on the network.
+
+ Returns:
+ List of miner dicts with keys: miner, antiquity_multiplier,
+ device_arch, device_family, hardware_type, last_attest, etc.
+
+ Example:
+ >>> clients.miners()[0]
+ {'miner': 'g4-powerbook-001', 'antiquity_multiplier': 2.5, 'device_arch': 'G4'}
+ """
+ return self._get("/api/miners")
+
+ def balance(self, wallet_id: str) -> Dict[str, Any]:
+ """
+ Get RTC balance for a wallet.
+
+ Args:
+ wallet_id: The wallet/miner ID to query.
+
+ Returns:
+ Dict with keys: amount_i64, amount_rtc, miner_id.
+
+ Example:
+ >>> client.balance("Ivan-houzhiwen")
+ {'amount_i64': 155000000, 'amount_rtc': 155.0, 'miner_id': 'Ivan-houzhiwen'}
+ """
+ if not wallet_id or not wallet_id.strip():
+ raise ValidationError("wallet_id cannot be empty")
+ return self._get(f"/wallet/balance?miner_id={wallet_id.strip()}")
+
+ def transfer(
+ self,
+ from_wallet: str,
+ to_wallet: str,
+ amount: int,
+ signature: str,
+ fee: int = 0,
+ timestamp: Optional[int] = None,
+ ) -> Dict[str, Any]:
+ """
+ Submit a signed RTC transfer.
+
+ Args:
+ from_wallet: Sender wallet ID.
+ to_wallet: Recipient wallet ID.
+ amount: Amount in smallest units (1 RTC = 1_000_000 units).
+ signature: Hex-encoded Ed25519 signature of the transfer payload.
+ fee: Transaction fee in smallest units (default 0).
+ timestamp: Unix timestamp for replay protection (default: now).
+
+ Returns:
+ Dict with keys: success, tx_hash, etc.
+
+ Example:
+ >>> client.transfer(
+ ... from_wallet="alice",
+ ... to_wallet="bob",
+ ... amount=1_000_000, # 1 RTC
+ ... signature="abc123...",
+ ... )
+ {'success': True, 'tx_hash': '...'}
+ """
+ if not from_wallet or not to_wallet:
+ raise ValidationError("from_wallet and to_wallet cannot be empty")
+ if amount <= 0:
+ raise ValidationError(f"amount must be positive, got {amount}")
+ if timestamp is None:
+ timestamp = int(time.time())
+
+ payload = {
+ "from": from_wallet,
+ "to": to_wallet,
+ "amount": amount,
+ "fee": fee,
+ "signature": signature,
+ "timestamp": timestamp,
+ }
+ return self._post("/wallet/transfer/signed", payload)
+
+ def attestation_status(self, miner_id: str) -> Dict[str, Any]:
+ """
+ Get attestation status for a miner.
+
+ Args:
+ miner_id: The miner ID to check.
+
+ Returns:
+ Dict with keys: miner_id, verified, last_attest, epochs_attested,
+ fingerprint_quality, antiquity_score, etc.
+
+ Example:
+ >>> client.attestation_status("g4-powerbook-001")
+ {'miner_id': 'g4-powerbook-001', 'verified': True, 'antiquity_score': 2.5}
+ """
+ if not miner_id or not miner_id.strip():
+ raise ValidationError("miner_id cannot be empty")
+ return self._get(f"/attest/status/{miner_id}")
+
+ # ── Signed transfer convenience ────────────────────────────────────────────
+
+ def transfer_signed(
+ self,
+ from_wallet: str,
+ to_wallet: str,
+ amount: int,
+ signing_key: "SigningKey",
+ fee: int = 0,
+ ) -> Dict[str, Any]:
+ """
+ Sign and submit a transfer in one call.
+
+ Args:
+ from_wallet: Sender wallet ID.
+ to_wallet: Recipient wallet ID.
+ amount: Amount in smallest units.
+ signing_key: Ed25519 SigningKey instance.
+ fee: Transaction fee in smallest units.
+
+ Returns:
+ Transfer result from the node.
+ """
+ sig, payload = signing_key.sign_transfer(from_wallet, to_wallet, amount, fee)
+ return self._post("/wallet/transfer/signed", {**payload, "signature": sig})
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Async Client
+# ─────────────────────────────────────────────────────────────────────────────
+
+
+class AsyncRustChainClient:
+ """
+ Async RustChain API client (uses aiohttp).
+
+ Example:
+ >>> async def main():
+ ... client = AsyncRustChainClient()
+ ... health = await client.health()
+ ... epoch = await client.epoch()
+ ...
+ >>> asyncio.run(main())
+ """
+
+ DEFAULT_BASE_URL = "https://50.28.86.131"
+
+ def __init__(
+ self,
+ base_url: str = DEFAULT_BASE_URL,
+ *,
+ timeout: int = 30,
+ verify_ssl: bool = False,
+ retry_count: int = 3,
+ ) -> None:
+ self.base_url = base_url.rstrip("/")
+ self.timeout = timeout
+ self.verify_ssl = verify_ssl
+ self.retry_count = retry_count
+ self._ctx: Optional[ssl.SSLContext] = None
+ if not verify_ssl:
+ self._ctx = ssl.create_default_context()
+ self._ctx.check_hostname = False
+ self._ctx.verify_mode = ssl.CERT_NONE
+
+ self.explorer = Explorer(
+ base_url=self.base_url,
+ timeout=self.timeout,
+ verify_ssl=self.verify_ssl,
+ )
+
+ def _ssl_context(self) -> Optional[ssl.SSLContext]:
+ return self._ctx
+
+ async def _request(
+ self,
+ method: str,
+ path: str,
+ json: Optional[Dict[str, Any]] = None,
+ params: Optional[Dict[str, Any]] = None,
+ ) -> Dict[str, Any]:
+ timeout = aiohttp.ClientTimeout(total=self.timeout)
+ for attempt in range(self.retry_count):
+ try:
+ async with aiohttp.ClientSession(timeout=timeout) as session:
+ async with session.request(
+ method,
+ f"{self.base_url}{path}",
+ json=json,
+ params=params,
+ ssl=self._ssl_context(),
+ ) as resp:
+ resp.raise_for_status()
+ return await resp.json()
+ except aiohttp.ClientResponseError as e:
+ if attempt == self.retry_count - 1:
+ raise APIError(
+ f"HTTP {e.status} on {path}: {e.message}",
+ status_code=e.status,
+ details={"path": path},
+ )
+ except TimeoutError:
+ raise TimeoutError(f"Request timed out after {self.timeout}s")
+ except Exception as e:
+ if attempt == self.retry_count - 1:
+ raise ConnectionError(f"Connection failed: {e}", details={"path": path})
+ raise ConnectionError("Max retries exceeded")
+
+ async def health(self) -> Dict[str, Any]:
+ return await self._request("GET", "/health")
+
+ async def epoch(self) -> Dict[str, Any]:
+ return await self._request("GET", "/epoch")
+
+ async def miners(self) -> List[Dict[str, Any]]:
+ return await self._request("GET", "/api/miners")
+
+ async def balance(self, wallet_id: str) -> Dict[str, Any]:
+ if not wallet_id or not wallet_id.strip():
+ raise ValidationError("wallet_id cannot be empty")
+ return await self._request("GET", f"/wallet/balance?miner_id={wallet_id.strip()}")
+
+ async def transfer(
+ self,
+ from_wallet: str,
+ to_wallet: str,
+ amount: int,
+ signature: str,
+ fee: int = 0,
+ timestamp: Optional[int] = None,
+ ) -> Dict[str, Any]:
+ if not from_wallet or not to_wallet:
+ raise ValidationError("from_wallet and to_wallet cannot be empty")
+ if amount <= 0:
+ raise ValidationError(f"amount must be positive, got {amount}")
+ payload = {
+ "from": from_wallet,
+ "to": to_wallet,
+ "amount": amount,
+ "fee": fee,
+ "signature": signature,
+ "timestamp": timestamp or int(time.time()),
+ }
+ return await self._request("POST", "/wallet/transfer/signed", json=payload)
+
+ async def attestation_status(self, miner_id: str) -> Dict[str, Any]:
+ if not miner_id or not miner_id.strip():
+ raise ValidationError("miner_id cannot be empty")
+ return await self._request("GET", f"/attest/status/{miner_id}")
+
+ async def transfer_signed(
+ self,
+ from_wallet: str,
+ to_wallet: str,
+ amount: int,
+ signing_key: "SigningKey",
+ fee: int = 0,
+ ) -> Dict[str, Any]:
+ sig, payload = signing_key.sign_transfer(from_wallet, to_wallet, amount, fee)
+ return await self._request(
+ "POST", "/wallet/transfer/signed", json={**payload, "signature": sig}
+ )
diff --git a/src/rustchain/crypto.py b/src/rustchain/crypto.py
new file mode 100644
index 000000000..2169bcf51
--- /dev/null
+++ b/src/rustchain/crypto.py
@@ -0,0 +1,136 @@
+"""
+RustChain Cryptographic Utilities
+Ed25519 signing helpers for signed transfers.
+"""
+
+import hashlib
+import hmac
+import struct
+import time
+from typing import Optional, Tuple
+
+# Try cryptography.io (recommended), fall back to ed25519-blake2b
+try:
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+ from cryptography.hazmat.primitives import serialization
+ from cryptography.hazmat.backends import default_backend
+ HAS_CRYPTOGRAPHY = True
+except ImportError:
+ HAS_CRYPTOGRAPHY = False
+
+try:
+ import ed25519
+ HAS_ED25519 = True
+except ImportError:
+ HAS_ED25519 = False
+
+
+class SigningKey:
+ """Ed25519 signing key wrapper compatible with RustChain."""
+
+ def __init__(self, private_key_bytes: bytes) -> None:
+ if HAS_CRYPTOGRAPHY:
+ self._key = Ed25519PrivateKey.from_private_bytes(private_key_bytes)
+ elif HAS_ED25519:
+ self._key = ed25519.SigningKey(private_key_bytes)
+ else:
+ raise ImportError(
+ "Ed25519 support requires either 'cryptography' or 'ed25519' package. "
+ "Install with: pip install rustchain[crypto]"
+ )
+
+ @classmethod
+ def generate(cls) -> "SigningKey":
+ """Generate a new Ed25519 signing key."""
+ if HAS_CRYPTOGRAPHY:
+ key = Ed25519PrivateKey.generate()
+ priv_bytes = key.private_bytes(
+ serialization.Encoding.Raw,
+ serialization.PrivateFormat.Raw,
+ serialization.NoEncryption(),
+ )
+ return cls(priv_bytes)
+ elif HAS_ED25519:
+ key = ed25519.SigningKey.generate()
+ return cls(key.to_bytes())
+ else:
+ raise ImportError("No Ed25519 library available")
+
+ @classmethod
+ def from_seed(cls, seed: bytes) -> "SigningKey":
+ """Derive a signing key from a seed (BIP39-style)."""
+ if len(seed) < 32:
+ seed = hashlib.sha256(seed).digest()
+ priv_bytes = hashlib.sha256(b"rustchain-wallet" + seed).digest()
+ return cls(priv_bytes)
+
+ def sign(self, message: bytes) -> bytes:
+ """Sign a message and return the Ed25519 signature (64 bytes)."""
+ if HAS_CRYPTOGRAPHY:
+ sig = self._key.sign(message)
+ return sig
+ elif HAS_ED25519:
+ return self._key.sign(message)
+ else:
+ raise ImportError("No Ed25519 library available")
+
+ def sign_transfer(
+ self,
+ from_wallet: str,
+ to_wallet: str,
+ amount: int,
+ fee: int = 0,
+ timestamp: Optional[int] = None,
+ ) -> Tuple[bytes, dict]:
+ """
+ Sign a transfer payload compatible with POST /wallet/transfer/signed.
+
+ Args:
+ from_wallet: Sender wallet ID
+ to_wallet: Recipient wallet ID
+ amount: Amount in smallest units (1 RTC = 1_000_000 units)
+ fee: Transaction fee in smallest units
+ timestamp: Unix timestamp (default: now)
+
+ Returns:
+ Tuple of (signature_hex, payload_dict)
+ """
+ if timestamp is None:
+ timestamp = int(time.time())
+
+ payload = {
+ "from": from_wallet,
+ "to": to_wallet,
+ "amount": amount,
+ "fee": fee,
+ "timestamp": timestamp,
+ }
+
+ # Canonical JSON bytes for signing (sorted keys, no whitespace)
+ import json
+ message = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode()
+ signature = self.sign(message)
+
+ return signature.hex(), payload
+
+
+def verify_signature(public_key_bytes: bytes, message: bytes, signature: bytes) -> bool:
+ """Verify an Ed25519 signature."""
+ if HAS_CRYPTOGRAPHY:
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
+ from cryptography.exceptions import InvalidSignature
+ pk = Ed25519PublicKey.from_public_bytes(public_key_bytes)
+ try:
+ pk.verify(signature, message)
+ return True
+ except InvalidSignature:
+ return False
+ elif HAS_ED25519:
+ try:
+ vk = ed25519.VerifyingKey(public_key_bytes)
+ vk.verify(signature, message)
+ return True
+ except Exception:
+ return False
+ else:
+ raise ImportError("No Ed25519 library available")
diff --git a/src/rustchain/exceptions.py b/src/rustchain/exceptions.py
new file mode 100644
index 000000000..35da291b4
--- /dev/null
+++ b/src/rustchain/exceptions.py
@@ -0,0 +1,58 @@
+"""
+RustChain SDK Exceptions
+Typed exceptions for all error conditions.
+"""
+
+from typing import Optional
+
+
+class RustChainError(Exception):
+ """Base exception for all RustChain SDK errors."""
+
+ def __init__(self, message: str, *, details: Optional[dict] = None) -> None:
+ super().__init__(message)
+ self.message = message
+ self.details = details
+
+
+class APIError(RustChainError):
+ """Raised when an API request fails (non-2xx response)."""
+
+ def __init__(
+ self,
+ message: str,
+ status_code: Optional[int] = None,
+ *,
+ details: Optional[dict] = None,
+ ) -> None:
+ super().__init__(message, details=details)
+ self.status_code = status_code
+
+ def __str__(self) -> str:
+ if self.status_code:
+ return f"[HTTP {self.status_code}] {self.message}"
+ return self.message
+
+
+class ConnectionError(RustChainError):
+ """Raised when the SDK cannot connect to a RustChain node."""
+
+
+class TimeoutError(RustChainError):
+ """Raised when an API request times out."""
+
+
+class ValidationError(RustChainError):
+ """Raised when input validation fails (bad wallet ID, negative amount, etc.)."""
+
+
+class WalletError(RustChainError):
+ """Raised for wallet-related errors (missing key, signing failure, etc.)."""
+
+
+class SigningError(WalletError):
+ """Raised when transaction signing fails."""
+
+
+class AttestationError(RustChainError):
+ """Raised when attestation submission fails."""
diff --git a/src/rustchain/explorer.py b/src/rustchain/explorer.py
new file mode 100644
index 000000000..0870ff848
--- /dev/null
+++ b/src/rustchain/explorer.py
@@ -0,0 +1,171 @@
+"""
+RustChain Explorer API
+Access block and transaction data from the RustChain explorer.
+"""
+
+from typing import Any, Dict, List, Optional
+
+import aiohttp
+import httpx
+
+from .exceptions import APIError, ConnectionError, TimeoutError
+
+
+class Explorer:
+ """
+ RustChain block explorer API access.
+
+ Example:
+ client = RustChainClient()
+ blocks = client.explorer.blocks(limit=10)
+ txs = client.explorer.transactions(limit=50)
+ """
+
+ def __init__(
+ self,
+ base_url: str = "https://50.28.86.131",
+ timeout: int = 30,
+ verify_ssl: bool = False,
+ _session: Optional[Any] = None,
+ ) -> None:
+ self.base_url = base_url.rstrip("/")
+ self.timeout = timeout
+ self.verify_ssl = verify_ssl
+ self._session = _session # shared session for async
+
+ # ─── Blocks ───────────────────────────────────────────────────────────────
+
+ def blocks(self, *, limit: int = 20, offset: int = 0) -> Dict[str, Any]:
+ """
+ Fetch recent blocks from the explorer.
+
+ Args:
+ limit: Number of blocks to return (max 100).
+ offset: Pagination offset.
+
+ Returns:
+ Dict with 'blocks' list and pagination metadata.
+
+ Example:
+ >>> client.explorer.blocks(limit=10)
+ {'blocks': [{'height': 1234, 'hash': '...', 'epoch': 5, ...}], 'total': 500}
+ """
+ return self._sync_request(
+ "GET",
+ "/blocks",
+ params={"limit": limit, "offset": offset},
+ )
+
+ def block_by_height(self, height: int) -> Dict[str, Any]:
+ """Get a single block by its height."""
+ return self._sync_request("GET", f"/blocks/{height}")
+
+ def block_by_hash(self, block_hash: str) -> Dict[str, Any]:
+ """Get a single block by its hash."""
+ return self._sync_request("GET", f"/blocks/hash/{block_hash}")
+
+ # ─── Transactions ─────────────────────────────────────────────────────────
+
+ def transactions(
+ self,
+ *,
+ limit: int = 50,
+ offset: int = 0,
+ wallet_id: Optional[str] = None,
+ ) -> Dict[str, Any]:
+ """
+ Fetch recent transactions from the explorer.
+
+ Args:
+ limit: Number of transactions to return (max 100).
+ offset: Pagination offset.
+ wallet_id: Filter transactions for a specific wallet.
+
+ Returns:
+ Dict with 'transactions' list and pagination metadata.
+
+ Example:
+ >>> client.explorer.transactions(limit=50)
+ {'transactions': [{'hash': '...', 'from': 'a', 'to': 'b', 'amount': 100}], 'total': 200}
+ """
+ params: Dict[str, Any] = {"limit": limit, "offset": offset}
+ if wallet_id:
+ params["wallet_id"] = wallet_id
+ return self._sync_request("GET", "/api/transactions", params=params)
+
+ def transaction_by_hash(self, tx_hash: str) -> Dict[str, Any]:
+ """Get a single transaction by its hash."""
+ return self._sync_request("GET", f"/api/transactions/{tx_hash}")
+
+ # ─── Sync helper ────────────────────────────────────────────────────────────
+
+ def _sync_request(
+ self,
+ method: str,
+ path: str,
+ params: Optional[Dict[str, Any]] = None,
+ json: Optional[Dict[str, Any]] = None,
+ ) -> Dict[str, Any]:
+ try:
+ import urllib.request
+ import urllib.error
+ import ssl
+ import json as _json
+
+ url = f"{self.base_url}{path}"
+ data = _json.dumps(json).encode() if json else None
+
+ ctx = ssl.create_default_context()
+ if not self.verify_ssl:
+ ctx.check_hostname = False
+ ctx.verify_mode = ssl.CERT_NONE
+
+ req = urllib.request.Request(
+ url,
+ data=data,
+ headers={"Content-Type": "application/json", "Accept": "application/json"},
+ method=method,
+ )
+
+ with urllib.request.urlopen(req, context=ctx, timeout=self.timeout) as resp:
+ return _json.loads(resp.read())
+
+ except urllib.error.HTTPError as e:
+ body = e.read().decode(errors="replace")
+ raise APIError(
+ f"HTTP {e.code} on {path}: {body}",
+ status_code=e.code,
+ details={"path": path},
+ )
+ except TimeoutError:
+ raise TimeoutError(f"Request to {path} timed out after {self.timeout}s")
+ except Exception as e:
+ raise ConnectionError(f"Failed to connect to explorer: {e}", details={"path": path})
+
+ # ─── Async helpers (called by AsyncRustChainClient) ────────────────────────
+
+ async def _async_request(
+ self,
+ method: str,
+ path: str,
+ params: Optional[Dict[str, Any]] = None,
+ json: Optional[Dict[str, Any]] = None,
+ ) -> Dict[str, Any]:
+ ssl_ctx = None
+ if not self.verify_ssl:
+ import ssl as ssl_module
+ ssl_ctx = ssl_module.create_default_context()
+ ssl_ctx.check_hostname = False
+ ssl_ctx.verify_mode = ssl_module.CERT_NONE
+
+ timeout = aiohttp.ClientTimeout(total=self.timeout)
+ async with aiohttp.ClientSession(timeout=timeout) as session:
+ async with session.request(
+ method,
+ f"{self.base_url}{path}",
+ params=params,
+ json=json,
+ ssl=ssl_ctx if ssl_ctx else None,
+ ) as resp:
+ resp.raise_for_status()
+ return await resp.json()
diff --git a/tests/conftest.py b/tests/conftest.py
index 52c17b4ad..4f602be31 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,48 +1,4 @@
-"""
-Pytest configuration for RustChain tests.
-"""
-
-import sys
-import sqlite3
+"""Shared pytest configuration."""
import pytest
-import os
-import importlib.util
-from pathlib import Path
-
-# Add project root and node directory to path
-project_root = Path(__file__).parent.parent
-sys.path.insert(0, str(project_root))
-sys.path.insert(0, str(project_root / "node"))
-
-# Mock environment variables required by the module at import time
-os.environ["RC_ADMIN_KEY"] = "0" * 32
-os.environ["DB_PATH"] = ":memory:"
-
-# Helper to load modules with non-standard names (containing dots)
-def load_node_module(module_name, file_name):
- if module_name in sys.modules:
- return sys.modules[module_name]
-
- node_dir = project_root / "node"
- spec = importlib.util.spec_from_file_location(module_name, str(node_dir / file_name))
- module = importlib.util.module_from_spec(spec)
- sys.modules[module_name] = module
- spec.loader.exec_module(module)
- return module
-
-# Mock rustchain_crypto before loading other modules
-from tests import mock_crypto
-sys.modules["rustchain_crypto"] = mock_crypto
-
-# Pre-load the modules to be shared across tests
-load_node_module("integrated_node", "rustchain_v2_integrated_v2.2.1_rip200.py")
-load_node_module("rewards_mod", "rewards_implementation_rip200.py")
-load_node_module("rr_mod", "rip_200_round_robin_1cpu1vote.py")
-load_node_module("tx_handler", "rustchain_tx_handler.py")
-@pytest.fixture
-def db_conn():
- """Provides an in-memory SQLite database connection."""
- conn = sqlite3.connect(":memory:")
- yield conn
- conn.close()
+pytest_plugins = ["pytest_asyncio"]
diff --git a/tests/test_client.py b/tests/test_client.py
new file mode 100644
index 000000000..c1c9d6ac6
--- /dev/null
+++ b/tests/test_client.py
@@ -0,0 +1,463 @@
+"""
+Comprehensive test suite for rustchain Python SDK.
+Tests client methods, async client, explorer, crypto, and CLI.
+"""
+
+import json
+import pytest
+import ssl
+import time
+from unittest.mock import MagicMock, patch
+
+from rustchain import (
+ RustChainClient,
+ AsyncRustChainClient,
+ SigningKey,
+)
+from rustchain.exceptions import (
+ APIError,
+ ConnectionError,
+ ValidationError,
+ WalletError,
+ SigningError,
+)
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Fixtures
+# ─────────────────────────────────────────────────────────────────────────────
+
+
+@pytest.fixture
+def client():
+ """Sync client pointed at a mock URL."""
+ return RustChainClient(base_url="https://example.test")
+
+
+@pytest.fixture
+def mock_urllib_response():
+ """Decorator that patches urllib to return mock JSON."""
+ def decorator(data: dict, status: int = 200):
+ mock_resp = MagicMock()
+ mock_resp.read.return_value = json.dumps(data).encode()
+ mock_resp.__enter__ = MagicMock(return_value=mock_resp)
+ mock_resp.__exit__ = MagicMock(return_value=None)
+
+ def raise_on(code):
+ from urllib.error import HTTPError
+ err = HTTPError("url", code, "msg", {}, None)
+ err.read = MagicMock(return_value=b'{"error": "test"}')
+ return err
+
+ mock_resp.raise_for_status = MagicMock()
+ if status >= 400:
+ mock_resp.raise_for_status.side_effect = raise_on(status)
+
+ return mock_resp
+ return decorator
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Test: RustChainClient.__init__
+# ─────────────────────────────────────────────────────────────────────────────
+
+class TestClientInit:
+ def test_default_base_url(self):
+ c = RustChainClient()
+ assert c.base_url == "https://50.28.86.131"
+
+ def test_custom_base_url(self):
+ c = RustChainClient(base_url="https://custom.node/api")
+ assert c.base_url == "https://custom.node/api"
+
+ def test_ssl_verify_false_creates_ctx(self):
+ c = RustChainClient(verify_ssl=False)
+ assert c._ctx is not None
+ assert c._ctx.verify_mode == ssl.CERT_NONE
+
+ def test_ssl_verify_true_no_ctx(self):
+ c = RustChainClient(verify_ssl=True)
+ assert c._ctx is None
+
+ def test_explorer_instance(self, client):
+ assert client.explorer is not None
+ from rustchain.explorer import Explorer
+ assert isinstance(client.explorer, Explorer)
+
+ def test_timeout_default(self):
+ c = RustChainClient()
+ assert c.timeout == 30
+
+ def test_timeout_custom(self):
+ c = RustChainClient(timeout=60)
+ assert c.timeout == 60
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Test: health()
+# ─────────────────────────────────────────────────────────────────────────────
+
+class TestHealth:
+ def test_health_returns_ok_field(self, client):
+ mock_data = {"ok": True, "version": "2.2.1-rip200", "uptime_s": 99999}
+ with patch.object(client, "_get", return_value=mock_data):
+ result = client.health()
+ assert result["ok"] is True
+ assert result["version"] == "2.2.1-rip200"
+
+ def test_health_parity_with_async(self, client):
+ """health() and async health() return equivalent shapes."""
+ mock_data = {"ok": True, "version": "2.2.1-rip200", "uptime_s": 123}
+ with patch.object(client, "_get", return_value=mock_data):
+ sync = client.health()
+ assert "ok" in sync
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Test: epoch()
+# ─────────────────────────────────────────────────────────────────────────────
+
+class TestEpoch:
+ def test_epoch_returns_epoch_field(self, client):
+ mock_data = {"epoch": 95, "slot": 12345, "height": 67890, "blocks_per_epoch": 144}
+ with patch.object(client, "_get", return_value=mock_data):
+ result = client.epoch()
+ assert result["epoch"] == 95
+ assert result["height"] == 67890
+
+ def test_epoch_negative_epoch_handled(self, client):
+ mock_data = {"epoch": -1, "error": "no epoch"}
+ with patch.object(client, "_get", return_value=mock_data):
+ result = client.epoch()
+ assert result.get("epoch", 0) < 0
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Test: miners()
+# ─────────────────────────────────────────────────────────────────────────────
+
+class TestMiners:
+ def test_miners_returns_list(self, client):
+ mock_data = [
+ {"miner": "g4-powerbook-001", "antiquity_multiplier": 2.5},
+ {"miner": "x86-modern-001", "antiquity_multiplier": 1.0},
+ ]
+ with patch.object(client, "_get", return_value=mock_data):
+ result = client.miners()
+ assert isinstance(result, list)
+ assert len(result) == 2
+ assert result[0]["antiquity_multiplier"] == 2.5
+
+ def test_miners_empty_list(self, client):
+ with patch.object(client, "_get", return_value=[]):
+ result = client.miners()
+ assert result == []
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Test: balance()
+# ─────────────────────────────────────────────────────────────────────────────
+
+class TestBalance:
+ def test_balance_valid_wallet(self, client):
+ mock_data = {"amount_i64": 1_550_000_00, "amount_rtc": 155.0, "miner_id": "tester"}
+ with patch.object(client, "_get", return_value=mock_data):
+ result = client.balance("tester")
+ assert result["amount_rtc"] == 155.0
+
+ def test_balance_empty_wallet_raises(self, client):
+ with pytest.raises(ValidationError, match="cannot be empty"):
+ client.balance("")
+
+ def test_balance_whitespace_only_raises(self, client):
+ with pytest.raises(ValidationError, match="cannot be empty"):
+ client.balance(" ")
+
+ def test_balance_strips_whitespace(self, client):
+ mock_data = {"amount_i64": 100, "amount_rtc": 0.0001, "miner_id": "clean"}
+ with patch.object(client, "_get", return_value=mock_data) as mock_get:
+ client.balance(" clean ")
+ call_arg = mock_get.call_args[0][0]
+ assert "miner_id=clean" in call_arg
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Test: transfer()
+# ─────────────────────────────────────────────────────────────────────────────
+
+class TestTransfer:
+ def test_transfer_success(self, client):
+ mock_data = {"success": True, "tx_hash": "0xabc123"}
+ with patch.object(client, "_post", return_value=mock_data):
+ result = client.transfer(
+ from_wallet="alice",
+ to_wallet="bob",
+ amount=1_000_000,
+ signature="deadbeef",
+ )
+ assert result["success"] is True
+
+ def test_transfer_negative_amount_raises(self, client):
+ with pytest.raises(ValidationError, match="positive"):
+ client.transfer("a", "b", -1, "sig")
+
+ def test_transfer_zero_amount_raises(self, client):
+ with pytest.raises(ValidationError, match="positive"):
+ client.transfer("a", "b", 0, "sig")
+
+ def test_transfer_empty_from_raises(self, client):
+ with pytest.raises(ValidationError, match="cannot be empty"):
+ client.transfer("", "b", 100, "sig")
+
+ def test_transfer_empty_to_raises(self, client):
+ with pytest.raises(ValidationError, match="cannot be empty"):
+ client.transfer("a", "", 100, "sig")
+
+ def test_transfer_passes_timestamp(self, client):
+ mock_data = {"success": True}
+ ts = 1_700_000_000
+ with patch.object(client, "_post", return_value=mock_data) as mock_post:
+ client.transfer("a", "b", 100, "sig", timestamp=ts)
+ args, _ = mock_post.call_args
+ payload = args[1]
+ assert payload["timestamp"] == ts
+
+ def test_transfer_default_timestamp(self, client):
+ mock_data = {"success": True}
+ with patch.object(client, "_post", return_value=mock_data) as mock_post:
+ before = int(time.time())
+ client.transfer("a", "b", 100, "sig")
+ after = int(time.time())
+ args, _ = mock_post.call_args
+ payload = args[1] # positional: (path, payload)
+ assert before <= payload["timestamp"] <= after
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Test: attestation_status()
+# ─────────────────────────────────────────────────────────────────────────────
+
+class TestAttestationStatus:
+ def test_attestation_status_valid(self, client):
+ mock_data = {
+ "miner_id": "g4-powerbook-001",
+ "verified": True,
+ "antiquity_score": 2.5,
+ "epochs_attested": 847,
+ }
+ with patch.object(client, "_get", return_value=mock_data):
+ result = client.attestation_status("g4-powerbook-001")
+ assert result["verified"] is True
+ assert result["antiquity_score"] == 2.5
+
+ def test_attestation_status_empty_miner_raises(self, client):
+ with pytest.raises(ValidationError, match="cannot be empty"):
+ client.attestation_status("")
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Test: transfer_signed()
+# ─────────────────────────────────────────────────────────────────────────────
+
+class TestTransferSigned:
+ def test_transfer_signed_uses_key(self, client):
+ """transfer_signed passes a signed payload to _post."""
+ fake_sig = "a" * 128
+ fake_payload = {
+ "from": "alice", "to": "bob", "amount": 1_000_000,
+ "fee": 0, "timestamp": 1_700_000_000,
+ }
+ mock_key = MagicMock()
+ mock_key.sign_transfer.return_value = (fake_sig, fake_payload)
+
+ with patch.object(client, "_post", return_value={"success": True}) as mock_post:
+ client.transfer_signed("alice", "bob", 1_000_000, mock_key)
+ args, _ = mock_post.call_args
+ payload = args[1]
+ assert payload["signature"] == fake_sig
+ assert payload["from"] == "alice"
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Test: SigningKey
+# ─────────────────────────────────────────────────────────────────────────────
+
+class TestSigningKey:
+ def test_generate_produces_key(self):
+ key = SigningKey.generate()
+ assert key is not None
+
+ def test_sign_produces_64_bytes(self):
+ key = SigningKey.generate()
+ sig = key.sign(b"hello world")
+ assert len(sig) == 64
+
+ def test_sign_transfer_returns_hex_and_payload(self):
+ key = SigningKey.generate()
+ sig_hex, payload = key.sign_transfer("alice", "bob", 1_000_000, 1000)
+ assert isinstance(sig_hex, str)
+ assert len(sig_hex) == 128
+ assert payload["from"] == "alice"
+ assert payload["to"] == "bob"
+ assert payload["amount"] == 1_000_000
+ assert payload["fee"] == 1000
+
+ def test_from_seed_reproducible(self):
+ key1 = SigningKey.from_seed(b"my seed phrase")
+ key2 = SigningKey.from_seed(b"my seed phrase")
+ sig1 = key1.sign(b"msg")
+ sig2 = key2.sign(b"msg")
+ assert sig1 == sig2 # Same seed → same key → same sig
+
+ def test_different_seeds_produce_different_keys(self):
+ key1 = SigningKey.from_seed(b"seed A")
+ key2 = SigningKey.from_seed(b"seed B")
+ sig1 = key1.sign(b"msg")
+ sig2 = key2.sign(b"msg")
+ assert sig1 != sig2
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Test: Retry logic
+# ─────────────────────────────────────────────────────────────────────────────
+
+class TestRetryLogic:
+ def test_retries_on_connection_error(self, client):
+ """Retry loop inside _urlopen retries on arbitrary exceptions."""
+ import urllib.error
+
+ call_count = 0
+
+ def urlopen_fails_then_ok(*args, **kwargs):
+ nonlocal call_count
+ call_count += 1
+ if call_count < 3:
+ raise urllib.error.URLError("Connection refused")
+ # Return a mock response object
+ mock_resp = MagicMock()
+ mock_resp.read.return_value = b'{"ok": true}'
+ mock_resp.__enter__ = MagicMock(return_value=mock_resp)
+ mock_resp.__exit__ = MagicMock(return_value=None)
+ return mock_resp
+
+ with patch("urllib.request.urlopen", side_effect=urlopen_fails_then_ok):
+ result = client._get("/health")
+ assert result.get("ok") is True
+ assert call_count == 3
+
+ def test_max_retries_exhausted_raises(self, client):
+ import urllib.error
+
+ def always_fail(*args, **kwargs):
+ raise urllib.error.URLError("Connection refused")
+
+ with patch("urllib.request.urlopen", side_effect=always_fail):
+ with pytest.raises(ConnectionError):
+ client._get("/health")
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Test: AsyncRustChainClient
+# ─────────────────────────────────────────────────────────────────────────────
+
+class TestAsyncClient:
+ @pytest.mark.asyncio
+ async def test_async_health(self):
+ client = AsyncRustChainClient()
+
+ async def mock_json(*args, **kwargs):
+ return {"ok": True, "version": "2.2.1-rip200"}
+
+ with patch("aiohttp.ClientSession") as mock_session_cls:
+ mock_session = MagicMock()
+ mock_session_cls.return_value.__aenter__.return_value = mock_session
+ mock_resp = MagicMock()
+ mock_resp.raise_for_status = MagicMock()
+ mock_resp.json = mock_json
+ mock_session.request.return_value.__aenter__.return_value = mock_resp
+
+ result = await client.health()
+ assert result["ok"] is True
+
+ @pytest.mark.asyncio
+ async def test_async_balance_validates_empty(self):
+ client = AsyncRustChainClient()
+ with pytest.raises(ValidationError, match="cannot be empty"):
+ await client.balance("")
+
+ @pytest.mark.asyncio
+ async def test_async_transfer_validates_negative_amount(self):
+ client = AsyncRustChainClient()
+ with pytest.raises(ValidationError, match="positive"):
+ await client.transfer("a", "b", -1, "sig")
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Test: Explorer
+# ─────────────────────────────────────────────────────────────────────────────
+
+class TestExplorer:
+ def test_explorer_blocks(self, client):
+ mock_data = {
+ "blocks": [{"height": 1, "hash": "abc"}, {"height": 2, "hash": "def"}],
+ "total": 100,
+ }
+ with patch.object(client.explorer, "_sync_request", return_value=mock_data):
+ result = client.explorer.blocks(limit=10)
+ assert len(result["blocks"]) == 2
+ assert result["total"] == 100
+
+ def test_explorer_transactions(self, client):
+ mock_data = {
+ "transactions": [{"hash": "tx1", "amount": 100}, {"hash": "tx2", "amount": 200}],
+ "total": 50,
+ }
+ with patch.object(client.explorer, "_sync_request", return_value=mock_data):
+ result = client.explorer.transactions(limit=50)
+ assert len(result["transactions"]) == 2
+
+ def test_explorer_transactions_wallet_filter(self, client):
+ mock_data = {"transactions": [], "total": 0}
+ with patch.object(client.explorer, "_sync_request", return_value=mock_data) as mock_req:
+ client.explorer.transactions(wallet_id="alice-wallet")
+ _, kwargs = mock_req.call_args
+ assert kwargs["params"]["wallet_id"] == "alice-wallet"
+
+ def test_explorer_block_by_height(self, client):
+ mock_data = {"height": 42, "hash": "block42"}
+ with patch.object(client.explorer, "_sync_request", return_value=mock_data):
+ result = client.explorer.block_by_height(42)
+ assert result["height"] == 42
+
+ def test_explorer_transaction_by_hash(self, client):
+ mock_data = {"hash": "txabc", "amount": 999}
+ with patch.object(client.explorer, "_sync_request", return_value=mock_data):
+ result = client.explorer.transaction_by_hash("txabc")
+ assert result["hash"] == "txabc"
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Test: Exception types
+# ─────────────────────────────────────────────────────────────────────────────
+
+class TestExceptions:
+ def test_api_error_has_status_code(self):
+ err = APIError("Not found", status_code=404)
+ assert err.status_code == 404
+ assert "404" in str(err)
+
+ def test_validation_error_message(self):
+ err = ValidationError("bad input")
+ assert "bad input" in str(err)
+
+ def test_connection_error_details(self):
+ err = ConnectionError("conn failed", details={"path": "/health"})
+ assert err.details["path"] == "/health"
+
+ def test_signing_error_is_wallet_error(self):
+ assert issubclass(SigningError, Exception)
+ assert issubclass(SigningError, WalletError)
+
+
+if __name__ == "__main__":
+ pytest.main([__file__, "-v"])