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`. -[![CI](https://github.com/Scottcjn/Rustchain/actions/workflows/ci.yml/badge.svg)](https://github.com/Scottcjn/Rustchain/actions/workflows/ci.yml) -[![License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) -[![GitHub Stars](https://img.shields.io/github/stars/Scottcjn/Rustchain?style=flat&color=gold)](https://github.com/Scottcjn/Rustchain/stargazers) -[![Contributors](https://img.shields.io/github/contributors/Scottcjn/Rustchain?color=brightgreen)](https://github.com/Scottcjn/Rustchain/graphs/contributors) -[![Last Commit](https://img.shields.io/github/last-commit/Scottcjn/Rustchain?color=blue)](https://github.com/Scottcjn/Rustchain/commits/main) -[![Open Issues](https://img.shields.io/github/issues/Scottcjn/Rustchain?color=orange)](https://github.com/Scottcjn/Rustchain/issues) -[![PowerPC](https://img.shields.io/badge/PowerPC-G3%2FG4%2FG5-orange)](https://github.com/Scottcjn/Rustchain) -[![Blockchain](https://img.shields.io/badge/Consensus-Proof--of--Antiquity-green)](https://github.com/Scottcjn/Rustchain) -[![Python](https://img.shields.io/badge/Python-3.x-yellow)](https://python.org) -[![Network](https://img.shields.io/badge/Nodes-3%20Active-brightgreen)](https://rustchain.org/explorer) -[![Bounties](https://img.shields.io/badge/Bounties-Open%20%F0%9F%92%B0-green)](https://github.com/Scottcjn/rustchain-bounties/issues) -[![As seen on BoTTube](https://bottube.ai/badge/seen-on-bottube.svg)](https://bottube.ai) -[![Discussions](https://img.shields.io/github/discussions/Scottcjn/Rustchain?color=purple)](https://github.com/Scottcjn/Rustchain/discussions) +[![PyPI version](https://img.shields.io/pypi/v/rustchain.svg)](https://pypi.org/project/rustchain/) +[![Python](https://img.shields.io/pypi/pyversions/rustchain.svg)](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** | [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.18623592.svg)](https://doi.org/10.5281/zenodo.18623592) | Proof of Antiquity consensus, hardware fingerprinting | -| **Non-Bijunctive Permutation Collapse** | [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.18623920.svg)](https://doi.org/10.5281/zenodo.18623920) | AltiVec vec_perm for LLM attention (27-96x advantage) | -| **PSE Hardware Entropy** | [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.18623922.svg)](https://doi.org/10.5281/zenodo.18623922) | POWER8 mftb entropy for behavioral divergence | -| **Neuromorphic Prompt Translation** | [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.18623594.svg)](https://doi.org/10.5281/zenodo.18623594) | Emotional prompting for 20% video diffusion gains | -| **RAM Coffers** | [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.18321905.svg)](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 - -![RustChain Mining Status](https://img.shields.io/endpoint?url=https://rustchain.org/api/badge/frozen-factorio-ryan&style=flat-square) +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"])