+Privacy-focused Ethereum JSON-RPC proxy served as a Tor hidden service, with
+optional MEV-protected sends via Flashbots.
-
-
-
-
+There are two binaries in this workspace:
-**Anonymous Ethereum RPC access through Tor with MEV protection**
+- **`torpc`** — the daemon. Runs alongside a Geth node, exposes a Tor
+ hidden service that filters JSON-RPC, rate-limits, and optionally signs
+ bundles for Flashbots. Operators run this.
+- **`torpc-proxy`** — the local client proxy. A wallet user runs this on
+ their own machine; it listens on `127.0.0.1:8545`, forwards each request
+ through Tor to the configured `.onion`, and returns the response.
-[Features](#features) • [Quick Start](#quick-start) • [Installation](#installation) • [Usage](#usage) • [Security](#security)
+> **Status**: working but not yet release-distributed. Build from source.
-
-
-## 🌟 Overview
-
-TorPC provides a privacy-preserving gateway to interact with Ethereum nodes through the Tor network. It acts as a reverse proxy that:
-
-- **Routes all RPC calls through Tor** for complete anonymity
-- **Protects against MEV** by routing transactions through Flashbots
-- **Filters dangerous RPC methods** to prevent wallet exposure
-- **Rate limits requests** per Tor circuit to prevent abuse
-- **Provides a simple web interface** for testing
-
-### Architecture
-
-```
-User → Tor Network → .onion address → TorPC Proxy → Geth Node
- ↓
- Flashbots Relay
-```
-
-## 📋 System Requirements
+---
-### Operating Systems
-- ✅ **Linux** (Ubuntu 20.04+, Debian 11+, Fedora 35+)
-- ✅ **macOS** (10.15 Catalina or later)
-- ✅ **Windows** (via WSL2)
+## Roles
-### Software Prerequisites
-- **Rust** 1.70 or later
-- **Geth** (go-ethereum) 1.10+
-- **Tor** 0.4.5+
-- **Foundry** (for testing) - latest version
+| Role | Goal | Path |
+|---|---|---|
+| Operator | Run a Tor-fronted RPC, optionally protect sends via Flashbots | [Operator Setup](#operator-setup) |
+| Wallet user | Route a wallet through someone else's TorPC `.onion` | [Wallet User Setup](#wallet-user-setup) |
+| Developer | Hack on TorPC | [Development](#development) |
-### Hardware Requirements
-- **RAM**: 4GB minimum (8GB recommended)
-- **Storage**: 10GB free space
-- **Network**: Stable internet connection
+---
-## 🚀 Quick Start
+## Operator Setup
-### One-Line Install (Linux/macOS)
+### Prerequisites
-```bash
-curl -sSL https://raw.githubusercontent.com/yourusername/torpc/main/install.sh | bash
-```
+- **Rust** 1.70+ (`rustup default stable`)
+- **Geth** 1.10+ (or any Ethereum execution client speaking JSON-RPC)
+- **Tor** 0.4.5+ (`apt install tor`, `brew install tor`, etc.)
+- Linux/macOS. Windows works under WSL2.
-### Manual Quick Setup
+### Build
```bash
-# 1. Clone the repository
-git clone https://github.com/yourusername/torpc.git
+git clone https://github.com/CPerezz/torpc.git
cd torpc
-
-# 2. Check prerequisites
-./scripts/check-prerequisites.sh
-
-# 3. Build the project
-cargo build --release
-
-# 4. Start all services
-./scripts/start-all-dev.sh
-
-# This script automatically starts:
-# - Geth development node
-# - Tor hidden service
-# - TorPC proxy server
-# No need to run 'cargo run' separately!
-
-# 5. Get your .onion address
-cat data/tor/torpc/hostname
+cargo build --release --bin torpc
```
-Your anonymous RPC endpoint will be available at: `http://YOUR-ONION-ADDRESS.onion/rpc`
-
-### Understanding start-all-dev.sh
-
-The `start-all-dev.sh` script is a convenient way to start all services with one command. It:
+The daemon binary lands in `target/release/torpc`.
-1. **Starts Geth** in development mode with a pre-funded account
-2. **Generates test blockchain data** (if needed) including:
- - Funded test accounts
- - Deployed smart contracts
- - Sample transactions
-3. **Starts Tor** and waits for the .onion address generation
-4. **Builds TorPC** (if needed) in release mode
-5. **Starts TorPC** proxy server on port 8080
+### Configure
-After running this script, all services are running in the background. You can:
-- Access the web interface at `http://localhost:8080`
-- View logs with the commands shown at the end of the script output
-- Stop everything with `./scripts/stop-all.sh`
+The daemon reads its config from environment variables. Every var (with
+defaults and effects) is documented in [`.env.example`](.env.example) — copy
+it to `.env` and edit in place; `dotenvy` auto-loads it at startup.
-## 🛠️ Detailed Installation
+The most common knobs:
-### Step 1: Install Rust
+```dotenv
+GETH_URL=http://127.0.0.1:8545 # Upstream Ethereum node
+BIND_ADDR=127.0.0.1:8080 # Tor hidden service forwards here
+RUST_LOG=info # Use `debug` to see the .onion address
-```bash
-# Install rustup (Rust installer)
-curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
-
-# Add Rust to PATH
-source $HOME/.cargo/env
-
-# Verify installation
-rustc --version
-cargo --version
+# Optional: Flashbots MEV protection. Without a signing key, sendBundle
+# returns JSON-RPC -32004 instead of silently faking a bundle hash.
+# FLASHBOTS_SIGNING_KEY=<64-hex-chars-no-0x>
+# FLASHBOTS_RELAY_URL=https://relay.flashbots.net
```
-### Step 2: Install Geth
-
-#### Option A: Using Package Manager
+### Tor
-**Ubuntu/Debian:**
-```bash
-sudo add-apt-repository -y ppa:ethereum/ethereum
-sudo apt-get update
-sudo apt-get install ethereum
-```
-
-**macOS:**
-```bash
-brew tap ethereum/ethereum
-brew install ethereum
-```
+The hidden service config lives at [`configs/torrc`](configs/torrc). The
+daemon parses it on startup and **refuses to start** if it disables anonymity
+(`HiddenServiceSingleHopMode 1` or `HiddenServiceNonAnonymousMode 1`) — set
+`TORPC_ALLOW_NON_ANONYMOUS=1` to override (CI/benchmarks only; never in
+production).
-**Fedora:**
```bash
-sudo dnf install ethereum
-```
-
-#### Option B: Download Binary
-
-```bash
-# Download latest Geth
-wget https://gethstore.blob.core.windows.net/builds/geth-linux-amd64-1.13.5-916d6a44.tar.gz
-
-# Extract
-tar -xvf geth-linux-amd64-*.tar.gz
-
-# Move to PATH
-sudo mv geth-linux-amd64-*/geth /usr/local/bin/
-
-# Verify
-geth version
-```
-
-### Step 3: Install Tor
-
-**Ubuntu/Debian:**
-```bash
-sudo apt update
-sudo apt install tor
+# First-time setup: generate the hidden service identity
+mkdir -p data/tor/torpc && chmod 700 data/tor/torpc
+tor -f configs/torrc
+# After Tor bootstraps (~30-60s):
+cat data/tor/torpc/hostname
+# 3g2upl4pq6kufc4m7v5jzvfncqvhv5bak6d2nprpz7hgbc6jvdnq5jqd.onion
```
-**macOS:**
-```bash
-brew install tor
-```
+The daemon does **not** print the .onion at INFO log level (it leaks into
+syslog/log shippers). Use `RUST_LOG=debug` or `cat` the hostname file.
-**Fedora:**
-```bash
-sudo dnf install tor
-```
+### Run
-### Step 4: Install Foundry (for testing)
+For local development, use the bundled scripts that start everything:
```bash
-# Install Foundry
-curl -L https://foundry.paradigm.xyz | bash
-
-# Follow the instructions to add foundryup to your PATH, then:
-foundryup
-
-# Verify installation
-cast --version
-forge --version
+./scripts/start-all-dev.sh # Geth --dev + Tor + daemon
+./scripts/stop-all.sh # Graceful shutdown
```
-## 🏃 Running TorPC
-
-### Method 1: Using Helper Scripts (Recommended)
-
-**Important:** The scripts require bash. Either make them executable or run with bash:
+For production, ship via systemd. Templates live in
+[`deploy/systemd-example/`](deploy/systemd-example/):
```bash
-# Make scripts executable (one-time setup)
-chmod +x scripts/*.sh
-
-# Then run directly:
-./scripts/start-all-dev.sh
-
-# OR run with bash explicitly:
-bash scripts/start-all-dev.sh
-
-# Start with verbose logging
-bash scripts/start-all-dev.sh --verbose
-
-# Start with full debug mode
-bash scripts/start-all-dev.sh --debug
+# Render and install (asks for the deploy paths):
+sudo deploy/systemd-example/install-systemd.sh
+sudo systemctl daemon-reload
+sudo systemctl enable --now torpc-tor torpc-daemon
-# Or start services individually:
-bash scripts/start-geth-dev.sh # Start Geth in development mode
-bash scripts/start-tor.sh # Start Tor hidden service
-cargo run --release # Start TorPC proxy
+# Logs and status
+sudo journalctl -u torpc-daemon -f
+sudo systemctl status torpc-daemon
```
-**Note:** Do NOT use `sh scripts/start-all-dev.sh` as it will use the wrong shell interpreter.
+The unit ships with `NoNewPrivileges`, `ProtectSystem=strict`, `PrivateTmp`,
+and `RestrictAddressFamilies` — read each `.service.template` before
+installing.
-### Method 2: Manual Setup
+### Observability
-#### 1. Start Geth Development Node
+`/health` and `/metrics` live on a **separate localhost-only listener**
+(default `127.0.0.1:9001`, set via `ADMIN_BIND_ADDR`). They are NOT
+reachable through the Tor hidden service — the .onion forwards only to
+`BIND_ADDR`. This is deliberate: those endpoints leak component state,
+request volume, uptime, and version, none of which should be visible to
+anonymous Tor visitors.
```bash
-# Create data directory
-mkdir -p data/geth-dev
-
-# Start Geth
-geth --dev \
- --http \
- --http.addr 127.0.0.1 \
- --http.port 8545 \
- --http.api eth,net,web3,personal,miner,txpool,debug \
- --http.corsdomain "*" \
- --datadir ./data/geth-dev \
- --mine
-```
-
-#### 2. Configure and Start Tor
-
-```bash
-# Create Tor data directory
-mkdir -p data/tor/torpc
-chmod 700 data/tor/torpc
-
-# Start Tor with our configuration
-tor -f configs/torrc
-```
-
-#### 3. Start TorPC Proxy
+curl http://127.0.0.1:9001/metrics | jq
+# {
+# "security_metrics": {
+# "blocked_requests_total": 0,
+# "invalid_methods": 0,
+# "rate_limit_hits": 0,
+# ...
+# },
+# "circuits": { "geth": "closed", "mev_relay": "disabled" }
+# }
-> **Note**: If you used `./scripts/start-all-dev.sh`, TorPC is already running!
-> The script automatically builds and starts TorPC for you.
-> Only run the commands below if you're starting services manually.
-
-```bash
-# Build and run TorPC (only if not using start-all-dev.sh)
-cargo build --release
-./target/release/torpc
-
-# Or with custom settings:
-GETH_URL=http://127.0.0.1:8545 \
-BIND_ADDR=127.0.0.1:8080 \
-RUST_LOG=info \
-./target/release/torpc
+curl http://127.0.0.1:9001/health
+# { "status": "ok", "uptime_seconds": 1234, ... }
```
-### Method 3: Using Docker
+For remote scraping (Prometheus, etc.), use an SSH tunnel or terminate
+inside your reverse proxy with auth. The daemon refuses to bind
+`ADMIN_BIND_ADDR` to a non-loopback address.
-```bash
-# Build Docker image
-docker build -t torpc .
+---
-# Run with Docker Compose
-docker-compose up -d
+## Wallet User Setup
-# View logs
-docker-compose logs -f
-```
+> **Heads up**: there are no pre-built binaries yet. You'll need a Rust
+> toolchain to build the client proxy.
-### Method 4: Systemd Services (Production)
+### Build
```bash
-# Install service files
-sudo cp services/*.service /etc/systemd/system/
-
-# Enable services
-sudo systemctl enable torpc-geth torpc-tor torpc-proxy
-
-# Start all services
-sudo systemctl start torpc-geth torpc-tor torpc-proxy
-
-# Check status
-sudo systemctl status torpc-*
+git clone https://github.com/CPerezz/torpc.git
+cd torpc
+cargo build --release -p torpc-proxy-cli
+# Binary at: target/release/torpc-proxy
```
-## 🛑 Stopping Services
+### Configure
-### Graceful Shutdown (Recommended)
+You need:
+- A running Tor daemon on your machine (`brew install tor && tor &` or your
+ distro's package — Tor Browser counts but uses port `9150` instead of `9050`).
+- The operator's `.onion` address.
-```bash
-# Stop all services gracefully (waits for processes to terminate)
-./scripts/stop-all.sh
+Create `torpc-proxy.toml` next to the binary (or pass `--config /path/to.toml`):
-# Stop with custom timeout (default is 30 seconds)
-./scripts/stop-all.sh --timeout 60
+```toml
+listen_addr = "127.0.0.1:8545"
+tor_proxy = "127.0.0.1:9050" # 9150 if you're using Tor Browser
+onion_endpoint = "your-operator.onion:80"
```
-### Force Shutdown
+### Run
```bash
-# Immediately kill all processes (use if graceful shutdown fails)
-./scripts/stop-all.sh --force
+./target/release/torpc-proxy start --config torpc-proxy.toml
```
-### Manual Process Management
+The proxy strips wallet-fingerprinting headers (User-Agent, Origin, Cookie,
+Authorization, Sec-CH-UA-*, etc.) before forwarding through Tor, replacing
+them with a synthetic `User-Agent: torpc-proxy/`. This is the
+point of the proxy — without it, your MetaMask/Coinbase/Rabby UA gives
+you up on every request.
-```bash
-# Check for running processes
-pgrep -f "geth.*--dev"
-pgrep -f "tor -f configs/torrc"
-pgrep -f "target/release/torpc"
+### Point your wallet
-# Kill specific processes manually
-kill -9
+In MetaMask / Coinbase Wallet / Rabby / Trust Wallet:
+- Add a custom RPC network
+- RPC URL: `http://127.0.0.1:8545`
+- Chain ID: `1` (Ethereum mainnet) — or whatever the operator's upstream
+ is configured for
+- Currency Symbol: `ETH`
-# Check if ports are freed
-lsof -ti:8545 # Geth RPC port
-lsof -ti:8546 # Geth WebSocket port
-```
+---
-The enhanced stop script will:
-- ✅ Wait for processes to terminate gracefully
-- ✅ Use force kill if graceful shutdown fails
-- ✅ Verify all processes are actually terminated
-- ✅ Check that ports are freed
-- ✅ Report accurate success/failure status
-- ✅ Handle multiple Geth instances properly
+## Configuration reference
+
+[`.env.example`](.env.example) documents every variable the daemon reads.
+Highlights:
+
+| Variable | Default | Effect |
+|---|---|---|
+| `GETH_URL` | `http://127.0.0.1:8545` | Upstream JSON-RPC |
+| `BIND_ADDR` | `127.0.0.1:8080` | Tor-facing listener (the hidden service forwards here) |
+| `ADMIN_BIND_ADDR` | `127.0.0.1:9001` | Localhost-only listener for `/health` and `/metrics` |
+| `RUST_LOG` | `info` | tracing-subscriber filter |
+| `MAX_REQUEST_SIZE` | `1048576` | Body-size cap (1 MiB) |
+| `RATE_LIMIT_REQUESTS` | `100` | Per-(IP, source-port) bucket per window |
+| `RATE_LIMIT_WINDOW` | `60` | Window in seconds |
+| `WRITE_RATE_LIMIT_REQUESTS` | `10` | Tighter bucket for `eth_sendRawTransaction`/`eth_sendBundle` |
+| `MAX_CONCURRENT_CONNECTIONS` | `256` | Router-wide in-flight cap |
+| `STRICT_SECURITY_HEADERS` | `true` | When `true`, no CORS. Default. |
+| `FLASHBOTS_SIGNING_KEY` | unset | Enables MEV protection when set |
+| `FLASHBOTS_RELAY_URL` | `https://relay.flashbots.net` | Mainnet by default |
+| `TORPC_ALLOW_NON_ANONYMOUS` | unset | Override anonymity check (CI only) |
-## 🧅 Understanding Tor Hidden Services
+---
-### How It Works
+## Threat model
+
+**This protects against**:
+- Network observers (ISP, VPN provider, RPC operator) seeing wallet→RPC
+ request contents and metadata. Tor circuits hide the user's IP.
+- The upstream RPC operator fingerprinting users via wallet-specific
+ headers (User-Agent, Origin, Sec-CH-UA-*) — the client proxy strips these.
+- The upstream RPC operator linking transactions to a specific wallet via
+ HTTP headers — same mechanism.
+- Method-level abuse (rate limits + a strict allow-list of JSON-RPC methods
+ blocks `eth_accounts`, `personal_*`, `admin_*`, etc.).
+- Front-running for MEV-protected sends — `eth_sendRawTransaction` on
+ `/rpc/flashbots` routes to Flashbots with EIP-191 auth.
+
+**This does NOT protect against**:
+- A malicious daemon operator. They see your raw JSON-RPC requests
+ (including transaction contents). The trust boundary is the operator,
+ same as any RPC.
+- Traffic-correlation attacks against Tor itself (a global passive
+ adversary, etc.).
+- A malicious wallet (the wallet sees keys and signs txs locally).
+- Smart-contract MEV (sandwich attacks at the protocol level — Flashbots
+ helps with sequencing, not contract-level exposure).
+- Side channels in the operator's environment (Geth logs, OS journal, etc.)
+ if the operator doesn't lock those down.
+
+The daemon goes to some lengths to avoid leaking operator identity to
+anonymous Tor visitors:
+- No `Server` or `X-Service` response header
+- Geth error bodies (which include version strings) are not forwarded
+- Per-tx routing decisions and bundle hashes are below INFO log level
+- The .onion address is below INFO log level
+- Strict CSP, no CORS by default
-1. **Automatic .onion Address Generation**: When Tor starts, it automatically generates a unique .onion address
-2. **Private Key Storage**: The private key is stored in `data/tor/torpc/`
-3. **No DNS Required**: .onion addresses work without any DNS configuration
-4. **End-to-End Encryption**: All traffic is encrypted through the Tor network
+---
-### Getting Your .onion Address
+## Development
-```bash
-# After starting Tor, get your address:
-cat data/tor/torpc/hostname
+### Layout
-# Example output:
-# 3g2upl4pq6kufc4m7v5jzvfncqvhv5bak6d2nprpz7hgbc6jvdnq5jqd.onion
```
-
-### Custom Vanity Addresses (Optional)
-
-Generate a custom .onion address starting with specific characters:
-
-```bash
-# Install mkp224o
-git clone https://github.com/cathugger/mkp224o.git
-cd mkp224o
-./autogen.sh
-./configure
-make
-
-# Generate address starting with "torpc"
-./mkp224o torpc
-
-# Copy generated keys to TorPC
-cp -r generated_address/* ../data/tor/torpc/
+torpc/
+├── src/ # Daemon
+│ ├── main.rs, app.rs, proxy.rs, ...
+│ └── mev/ # Flashbots auth + retry + circuit breaker
+├── torpc-proxy/ # Client workspace members
+│ ├── torpc-proxy-core/
+│ ├── torpc-proxy-cli/
+│ └── torpc-proxy-gui/ # Tauri desktop UI
+├── tests/ # Daemon integration tests
+├── static/ # Operator-facing wallet onboarding UI
+├── configs/torrc # Tor hidden service config
+├── deploy/systemd-example/ # Templated systemd units
+└── scripts/ # Dev convenience scripts
```
-### Testing Tor Connectivity
+### Test
```bash
-# Test using torsocks and curl
-torsocks curl http://YOUR-ONION-ADDRESS.onion/
-
-# Test RPC endpoint
-torsocks curl -X POST http://YOUR-ONION-ADDRESS.onion/rpc \
- -H "Content-Type: application/json" \
- -d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}'
+make test # Fast suite, no daemons. ~140 tests in <30s.
+make test-with-services # Brings up Geth + Tor + daemon, runs the whole set.
```
-## 🧪 Testing & Verification
+CI runs `make test`, `cargo fmt --check`, `cargo clippy --workspace
+--all-targets -- -D warnings`, and `cargo audit` (signal-only for now).
-### 1. Generate Test Blockchain Data
+### Local dev loop
```bash
-# Run the test data generation script
-./scripts/generate-test-data.sh
-
-# Run with verbose output
-./scripts/generate-test-data.sh --verbose
-
-# Run with debug mode for troubleshooting
-./scripts/generate-test-data.sh --debug
-
-# This will:
-# - Create test accounts with ETH
-# - Deploy smart contracts
-# - Generate 100+ blocks with transactions
-# - Output test account details
-```
-
-### 2. Test Using Web Interface
-
-1. Open your browser (regular or Tor Browser)
-2. Navigate to `http://localhost:8080` or `http://YOUR-ONION-ADDRESS.onion`
-3. Use the interactive interface to test RPC calls
-
-### 3. Test Using Command Line
+# In one terminal:
+./scripts/start-all-dev.sh
-```bash
-# Test local endpoint
+# In another:
curl -X POST http://localhost:8080/rpc \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}'
-# Test through Tor
-torsocks curl -X POST http://YOUR-ONION-ADDRESS.onion/rpc \
- -H "Content-Type: application/json" \
- -d '{"jsonrpc":"2.0","method":"eth_getBalance","params":["0x742d35Cc6634C0532925a3b844Bc9e7595f7777","latest"],"id":1}'
-
-# Test MEV protection endpoint
-torsocks curl -X POST http://YOUR-ONION-ADDRESS.onion/rpc/flashbots \
- -H "Content-Type: application/json" \
- -d '{"jsonrpc":"2.0","method":"eth_sendRawTransaction","params":["0xRAW_TX_HEX"],"id":1}'
-```
-
-### 4. Test Using Foundry Cast
-
-```bash
-# Set RPC URL
-export ETH_RPC_URL=http://localhost:8080/rpc
-
-# Get latest block
-cast block-number
-
-# Get account balance
-cast balance 0x742d35Cc6634C0532925a3b844Bc9e7595f7777
-
-# Send transaction (will route through Flashbots)
-cast send --private-key $PRIVATE_KEY --value 0.1ether $TO_ADDRESS --rpc-url http://localhost:8080/rpc/flashbots
-```
-
-### 5. Run Integration Tests
-
-```bash
-# Run all tests
-cargo test
-
-# Run specific test suites
-cargo test --test integration
-cargo test whitelist
-cargo test proxy
-
-# Run with verbose output
-RUST_LOG=debug cargo test -- --nocapture
-```
-
-## ⚙️ Configuration
-
-### Environment Variables
-
-```bash
-# Geth connection
-GETH_URL=http://127.0.0.1:8545 # Geth RPC endpoint
-FLASHBOTS_URL=https://relay.flashbots.net # Flashbots relay
-
-# Server settings
-BIND_ADDR=127.0.0.1:8080 # Proxy bind address
-RUST_LOG=info # Log level (trace/debug/info/warn/error)
-
-# Rate limiting
-RATE_LIMIT_REQUESTS=60 # Max requests per window
-RATE_LIMIT_WINDOW=60 # Window duration in seconds
-
-# Security settings
-MAX_REQUEST_SIZE=524288 # Max request body size (default: 512KB)
-REQUEST_TIMEOUT=30 # Request timeout in seconds
-STRICT_SECURITY_HEADERS=false # Enable strict security (no CORS)
-
-# Tor settings (if using SOCKS)
-TOR_SOCKS_PROXY=127.0.0.1:9050 # Tor SOCKS proxy address
-```
-
-### Configuration Files
-
-#### `configs/torrc` - Tor Configuration
-
-```ini
-# Hidden service settings
-HiddenServiceDir ./data/tor/torpc/
-HiddenServicePort 80 127.0.0.1:8080
-HiddenServiceVersion 3
-
-# Performance
-HiddenServiceMaxStreams 100
-HiddenServiceMaxStreamsCloseCircuit 1
-
-# Security
-SocksPort 0 # Disable SOCKS if only using hidden service
-
-# Logging
-Log notice file ./data/tor/tor.log
-DataDirectory ./data/tor
-```
-
-#### Rate Limiting Configuration
-
-Edit `src/rate_limit.rs` to adjust rate limiting:
-
-```rust
-pub struct RateLimitConfig {
- pub max_requests: u32, // Default: 60
- pub window_duration: Duration, // Default: 60 seconds
-}
-```
-
-## 📖 Usage Examples
-
-### Basic RPC Calls
-
-```bash
-# Get current block number
-curl -X POST http://localhost:8080/rpc \
+# Through Tor (after Tor bootstraps):
+torsocks curl -X POST http://$(cat data/tor/torpc/hostname)/rpc \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}'
-
-# Get account balance
-curl -X POST http://localhost:8080/rpc \
- -H "Content-Type: application/json" \
- -d '{
- "jsonrpc":"2.0",
- "method":"eth_getBalance",
- "params":["0x742d35Cc6634C0532925a3b844Bc9e7595f7777", "latest"],
- "id":1
- }'
-
-# Estimate gas
-curl -X POST http://localhost:8080/rpc \
- -H "Content-Type: application/json" \
- -d '{
- "jsonrpc":"2.0",
- "method":"eth_estimateGas",
- "params":[{
- "from": "0x742d35Cc6634C0532925a3b844Bc9e7595f7777",
- "to": "0x742d35Cc6634C0532925a3b844Bc9e7595f8888",
- "value": "0x9184e72a"
- }],
- "id":1
- }'
-```
-
-### MEV-Protected Transactions
-
-```bash
-# Send transaction through Flashbots
-curl -X POST http://localhost:8080/rpc/flashbots \
- -H "Content-Type: application/json" \
- -d '{
- "jsonrpc":"2.0",
- "method":"eth_sendRawTransaction",
- "params":["0xf86c0185..."], # Your signed transaction
- "id":1
- }'
-```
-
-### Batch Requests
-
-```bash
-# Send multiple requests in one call
-curl -X POST http://localhost:8080/rpc \
- -H "Content-Type: application/json" \
- -d '[
- {"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1},
- {"jsonrpc":"2.0","method":"net_version","params":[],"id":2},
- {"jsonrpc":"2.0","method":"eth_gasPrice","params":[],"id":3}
- ]'
-```
-
-## 🔒 Security Features & Configuration
-
-### Security Headers
-
-TorPC implements comprehensive security headers to protect against common web attacks:
-
-```bash
-# Security headers applied to all responses:
-# - X-Content-Type-Options: nosniff (prevents MIME type sniffing)
-# - X-Frame-Options: DENY (prevents clickjacking)
-# - X-XSS-Protection: 0 (disables legacy XSS filter)
-# - Referrer-Policy: no-referrer (prevents onion address leaks)
-# - Content-Security-Policy: default-src 'none' (strict CSP for API)
-# - Cache-Control: no-store, no-cache, must-revalidate
-# - Server header sanitized to prevent fingerprinting
-```
-
-### Request Size Limits
-
-Protection against DoS attacks through configurable request size limits:
-
-```bash
-# Default: 512KB (sufficient for most Ethereum RPC requests)
-export MAX_REQUEST_SIZE=524288
-
-# For batch operations: 2MB
-export MAX_REQUEST_SIZE=2097152
-
-# Request timeout (default: 30 seconds)
-export REQUEST_TIMEOUT=30
-
-# Strict security headers (disables CORS)
-export STRICT_SECURITY_HEADERS=true
-```
-
-### CORS Configuration
-
-By default, TorPC allows CORS for maximum compatibility:
-
-```bash
-# Standard mode (with CORS - default)
-export STRICT_SECURITY_HEADERS=false
-
-# Strict mode (no CORS - recommended for production)
-export STRICT_SECURITY_HEADERS=true
-```
-
-## 🔒 Security Best Practices
-
-### 1. Firewall Configuration
-
-```bash
-# Block external access to Geth and TorPC
-sudo ufw deny 8545/tcp # Block Geth
-sudo ufw deny 8080/tcp # Block TorPC direct access
-
-# Allow only localhost connections
-sudo iptables -A INPUT -p tcp --dport 8545 -s 127.0.0.1 -j ACCEPT
-sudo iptables -A INPUT -p tcp --dport 8545 -j DROP
-sudo iptables -A INPUT -p tcp --dport 8080 -s 127.0.0.1 -j ACCEPT
-sudo iptables -A INPUT -p tcp --dport 8080 -j DROP
-```
-
-### 2. Tor Circuit Isolation
-
-Each connection through Tor uses a separate circuit, ensuring:
-- No correlation between different users
-- Isolated rate limiting per circuit
-- Enhanced privacy
-
-### 3. Key Security
-
-```bash
-# Secure Tor keys
-chmod 700 data/tor/torpc
-chmod 600 data/tor/torpc/hs_ed25519_secret_key
-
-# Backup your keys securely
-cp -r data/tor/torpc /secure/backup/location/
-```
-
-### 4. Operational Security
-
-- ✅ **DO**: Always use Tor Browser or torsocks for anonymous access
-- ✅ **DO**: Regularly update all components
-- ✅ **DO**: Monitor logs for suspicious activity
-- ❌ **DON'T**: Share your .onion address publicly unless intended
-- ❌ **DON'T**: Run on a VPS that requires KYC
-- ❌ **DON'T**: Use the same .onion for multiple services
-
-## 🔧 Troubleshooting
-
-### Common Issues
-
-#### Geth Won't Start
-
-```bash
-# Check if port is already in use
-lsof -i :8545
-
-# Kill existing process
-kill -9 $(lsof -t -i:8545)
-
-# Check Geth logs
-tail -f data/geth-dev/geth.log
```
-#### Tor Connection Failed
+The static UI at serves two templates depending
+on the request `Host` header:
-```bash
-# Check Tor status
-systemctl status tor
-
-# Check Tor logs
-tail -f data/tor/tor.log
-
-# Verify torrc syntax
-tor --verify-config -f configs/torrc
-
-# Check permissions
-ls -la data/tor/torpc/
-```
+- **Operator dashboard** (`Host: 127.0.0.1:8080` or any non-`.onion`):
+ endpoint reference, JSON-RPC test panel, self-test wallet flows. This
+ is what the operator sees when they `curl` or browse the bind address
+ directly.
+- **Wallet-onboarding flow** (`Host: .onion`): install-the-
+ client step gated by a JS probe of `localhost:8545`, then a wallet
+ picker (MetaMask / Coinbase Wallet / Rabby / Trust Wallet) with copy-
+ to-clipboard RPC URL and per-wallet "Add network" buttons. This is
+ what visitors see when they reach the daemon through Tor.
-#### TorPC Build Errors
-
-```bash
-# Update Rust
-rustup update
-
-# Clean and rebuild
-cargo clean
-cargo build --release
-
-# Check dependencies
-cargo tree
-```
-
-#### Rate Limiting Issues
-
-```bash
-# Increase rate limits
-export RATE_LIMIT_REQUESTS=120
-export RATE_LIMIT_WINDOW=60
-
-# Or modify in code and rebuild
-```
-
-### Debug Mode
-
-```bash
-# Enable debug logging
-RUST_LOG=debug ./target/release/torpc
-
-# Enable Tor debug logs
-echo "Log debug file ./data/tor/debug.log" >> configs/torrc
-```
-
-### Performance Issues
-
-1. **Slow Responses**:
- - Check Geth sync status: `cast block-number`
- - Verify Tor circuit health
- - Consider increasing rate limits
-
-2. **High Memory Usage**:
- - Limit Geth cache: `--cache 1024`
- - Reduce Tor MaxStreams
-
-3. **Connection Drops**:
- - Check firewall rules
- - Verify Tor is running
- - Monitor system resources
-
-## 📚 Advanced Topics
-
-### Custom RPC Method Whitelisting
-
-Edit `src/whitelist.rs` to modify allowed methods:
-
-```rust
-const ALLOWED_METHODS: &[&str] = &[
- // Add your custom methods here
- "eth_blockNumber",
- "eth_getBalance",
- // ...
-];
-```
-
-### Flashbots Integration
-
-The `/rpc/flashbots` endpoint automatically routes transactions to prevent MEV:
-
-1. `eth_sendRawTransaction` → Flashbots Relay
-2. Other methods → Local Geth node
-
-To add Flashbots authentication:
-```rust
-// In src/proxy.rs
-headers.insert("X-Flashbots-Signature", signature);
-```
-
-### Monitoring & Metrics
-
-```bash
-# Add Prometheus metrics (coming soon)
-cargo add prometheus
-
-# Export metrics endpoint
-METRICS_ADDR=127.0.0.1:9090
-```
-
-### Docker Deployment
-
-```dockerfile
-# Dockerfile
-FROM rust:1.70 as builder
-WORKDIR /app
-COPY . .
-RUN cargo build --release
-
-FROM debian:bullseye-slim
-RUN apt-get update && apt-get install -y tor geth
-COPY --from=builder /app/target/release/torpc /usr/local/bin/
-COPY configs /etc/torpc/
-CMD ["torpc"]
-```
-
-```yaml
-# docker-compose.yml
-version: '3.8'
-services:
- geth:
- image: ethereum/client-go:latest
- command: --dev --http --http.addr 0.0.0.0
- volumes:
- - geth-data:/data
-
- tor:
- image: osminogin/tor-simple
- volumes:
- - tor-data:/var/lib/tor
- - ./configs/torrc:/etc/tor/torrc
-
- torpc:
- build: .
- depends_on:
- - geth
- - tor
- environment:
- - GETH_URL=http://geth:8545
- ports:
- - "8080:8080"
-
-volumes:
- geth-data:
- tor-data:
-```
-
-## 🤝 Contributing
-
-We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
-
-### Development Setup
-
-```bash
-# Fork and clone
-git clone https://github.com/yourusername/torpc.git
-cd torpc
-
-# Create feature branch
-git checkout -b feature/your-feature
-
-# Install dev dependencies
-cargo install cargo-watch cargo-audit
-
-# Run in watch mode
-cargo watch -x run
-
-# Run tests on change
-cargo watch -x test
-```
-
-## 📜 License
-
-This project is licensed under the MIT License - see [LICENSE](LICENSE) for details.
-
-## 🙏 Acknowledgments
-
-- [Tor Project](https://www.torproject.org/) for anonymous networking
-- [Ethereum Foundation](https://ethereum.org/) for the blockchain
-- [Flashbots](https://flashbots.net/) for MEV protection
-- [Rust Community](https://www.rust-lang.org/) for the amazing ecosystem
+Source-of-truth templates are `static/index_operator.html` and
+`static/index_user.html`; routing lives in `src/app.rs::serve_root`.
---
-
-
-**Built with 🦀 Rust and 🧅 Tor for a more private Ethereum**
+## License
-[Report Bug](https://github.com/yourusername/torpc/issues) • [Request Feature](https://github.com/yourusername/torpc/issues)
+MIT — see [LICENSE](LICENSE).
-
\ No newline at end of file
+Built with 🦀 Rust and 🧅 Tor.
diff --git a/src/app.rs b/src/app.rs
index f76fb09..703fc7f 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -26,6 +26,9 @@ use tower_http::set_header::SetResponseHeaderLayer;
use tower_http::trace::TraceLayer;
use tracing::{error, info};
+use axum::http::HeaderMap;
+use axum::response::Html;
+
use crate::mev::mev_handler::{handle_flashbots_with_mev, MevProxyState};
use crate::mev::{create_mev_client, MevConfig};
use crate::proxy::{self, handle_rpc, ProxyState};
@@ -35,6 +38,42 @@ use crate::security::{
SecurityConfig, STATIC_CSP,
};
+/// Operator dashboard — test panel + self-test wallet flows + endpoint
+/// reference. Served when the visitor reaches the daemon by `127.0.0.1`,
+/// LAN, or any non-`.onion` host.
+const INDEX_OPERATOR_HTML: &str = include_str!("../static/index_operator.html");
+
+/// Wallet-onboarding flow — install-the-client step + wallet picker, gated
+/// by a JS probe of `localhost:8545`. Served when the `Host` header ends
+/// in `.onion`, i.e. the visitor reached us through Tor.
+const INDEX_USER_HTML: &str = include_str!("../static/index_user.html");
+
+/// Pick which `index_*.html` template to return based on the request `Host`.
+/// `.onion` host → user-facing wallet-onboarding template; anything else →
+/// operator dashboard.
+///
+/// Ports are stripped before suffix matching, so an operator binding to a
+/// non-default port still sees the operator template, and Tor Browser
+/// hitting `:80` sees the user template.
+///
+/// Spoofing concern: an attacker on the operator's LAN can send
+/// `Host: foo.onion` and force the user template. They get an install-
+/// the-client page that exposes no operator state — the JS probe runs in
+/// *their* browser against *their* localhost. Not a leak.
+async fn serve_root(headers: HeaderMap) -> Html<&'static str> {
+ let host = headers
+ .get(axum::http::header::HOST)
+ .and_then(|h| h.to_str().ok())
+ .unwrap_or("");
+ let hostname = host.split(':').next().unwrap_or("").to_ascii_lowercase();
+
+ if hostname.ends_with(".onion") {
+ Html(INDEX_USER_HTML)
+ } else {
+ Html(INDEX_OPERATOR_HTML)
+ }
+}
+
/// All operator-configurable knobs the daemon needs at startup. Construct
/// via `from_env()` for production or by literal in tests.
#[derive(Clone, Debug)]
@@ -42,6 +81,11 @@ pub struct AppConfig {
pub geth_url: String,
pub flashbots_url: String,
pub bind_addr: String,
+ /// Bind address for the admin listener (`/health`, `/metrics`). Kept on
+ /// a separate socket from `bind_addr` so admin endpoints are not
+ /// reachable through the Tor hidden service — the .onion forwards only
+ /// to `bind_addr`. Default `127.0.0.1:9001`.
+ pub admin_bind_addr: String,
/// Hex-encoded private key used to sign Flashbots auth headers. `None`
/// disables MEV protection (bundles return a JSON-RPC error rather than
@@ -71,12 +115,13 @@ impl AppConfig {
std::env::var("GETH_URL").unwrap_or_else(|_| "http://127.0.0.1:8545".to_string());
let flashbots_url = std::env::var("FLASHBOTS_URL")
.unwrap_or_else(|_| "https://relay.flashbots.net".to_string());
- let bind_addr =
- std::env::var("BIND_ADDR").unwrap_or_else(|_| "127.0.0.1:8080".to_string());
+ let bind_addr = std::env::var("BIND_ADDR").unwrap_or_else(|_| "127.0.0.1:8080".to_string());
+ let admin_bind_addr =
+ std::env::var("ADMIN_BIND_ADDR").unwrap_or_else(|_| "127.0.0.1:9001".to_string());
let mev_signing_key = std::env::var("FLASHBOTS_SIGNING_KEY").ok();
- let mev_relay_url = std::env::var("FLASHBOTS_RELAY_URL")
- .unwrap_or_else(|_| flashbots_url.clone());
+ let mev_relay_url =
+ std::env::var("FLASHBOTS_RELAY_URL").unwrap_or_else(|_| flashbots_url.clone());
let mev_request_timeout = Duration::from_secs(env_u64("FLASHBOTS_REQUEST_TIMEOUT", 5));
let rate_limit = RateLimitConfig {
@@ -97,6 +142,7 @@ impl AppConfig {
geth_url,
flashbots_url,
bind_addr,
+ admin_bind_addr,
mev_signing_key,
mev_relay_url,
mev_request_timeout,
@@ -117,6 +163,7 @@ impl AppConfig {
geth_url,
flashbots_url: "http://127.0.0.1:1".to_string(),
bind_addr: "127.0.0.1:0".to_string(),
+ admin_bind_addr: "127.0.0.1:0".to_string(),
mev_signing_key: None,
mev_relay_url: "http://127.0.0.1:1".to_string(),
mev_request_timeout: Duration::from_secs(2),
@@ -135,11 +182,25 @@ impl AppConfig {
}
}
-/// What `build_app` returns. Holding the cleanup `JoinHandle` lets callers
-/// abort the rate-limiter cleanup task on shutdown — the task otherwise
-/// runs until the runtime drops.
+/// What `build_app` returns. Two routers (one Tor-facing, one operator-only)
+/// share the same `MevProxyState` so `/metrics` reflects live activity from
+/// the public router. The cleanup `JoinHandle` lets callers abort the
+/// rate-limiter cleanup task on shutdown.
+///
+/// **Why two routers?** `/health` and `/metrics` leak operator-side state
+/// (component circuit-breaker status, request volume counters, uptime,
+/// version) and have no consumer over Tor. Splitting them onto a separate
+/// localhost-bound listener means anyone reaching the .onion sees a 404
+/// for those paths.
pub struct BuiltApp {
+ /// Tor-facing router: `/rpc`, `/rpc/flashbots`, static UI, full security
+ /// middleware stack. This is what `bind_addr` listens on.
pub app: Router,
+ /// Operator-only router: `/health`, `/metrics`. Bound to `admin_bind_addr`
+ /// (default 127.0.0.1:9001). Carries security headers but skips the
+ /// JSON-RPC timeout / body-limit / rate-limit layers — those are noise
+ /// for a localhost admin surface.
+ pub admin_app: Router,
pub cleanup_task: tokio::task::JoinHandle<()>,
}
@@ -189,7 +250,10 @@ pub async fn build_app(config: AppConfig) -> anyhow::Result {
};
match create_mev_client(mev_config) {
Ok(client) => {
- info!("MEV protection enabled with relay: {}", config.mev_relay_url);
+ info!(
+ "MEV protection enabled with relay: {}",
+ config.mev_relay_url
+ );
Arc::new(MevProxyState {
base_state: base_state.clone(),
mev_client: Some(client),
@@ -223,10 +287,18 @@ pub async fn build_app(config: AppConfig) -> anyhow::Result {
}
});
- // ----- Router assembly --------------------------------------------------
+ // ----- Public (Tor-facing) router ---------------------------------------
+ // /health and /metrics deliberately live on the admin router, NOT here.
+ // Reaching them through the .onion would leak component state, request
+ // volume, uptime, and version to anonymous Tor visitors. Path resolution
+ // is route-tree based — they will 404 on this router.
+ //
+ // GET / serves one of two templates based on the request `Host` header:
+ // visitors via .onion get `index_user.html`; operators on LAN/loopback
+ // get `index_operator.html`. Static assets (app.js, helpers, style.css)
+ // are served by ServeDir as the fallback.
let app = Router::new()
- .route("/health", get(health_check))
- .route("/metrics", get(security_metrics))
+ .route("/", get(serve_root))
.route(
"/rpc",
post({
@@ -249,8 +321,8 @@ pub async fn build_app(config: AppConfig) -> anyhow::Result {
rate_limiter.clone(),
rate_limit_middleware,
))
- .nest_service("/", ServeDir::new(&config.static_dir))
- .with_state(mev_state)
+ .fallback_service(ServeDir::new(&config.static_dir))
+ .with_state(mev_state.clone())
.layer(ConcurrencyLimitLayer::new(config.max_concurrent))
.layer(DefaultBodyLimit::max(config.security.max_body_size))
.layer(middleware::from_fn_with_state(
@@ -259,16 +331,31 @@ pub async fn build_app(config: AppConfig) -> anyhow::Result {
))
.layer(middleware::from_fn(security_headers_middleware))
// Static CSP — see `STATIC_CSP` in `security.rs` for rationale on
- // why this is no longer built from runtime env. Operators who
- // customise the discovery server's port and want the wallet
- // auto-detect to pass CSP must override via a reverse proxy.
+ // why this is no longer built from runtime env.
.layer(SetResponseHeaderLayer::overriding(
axum::http::header::CONTENT_SECURITY_POLICY,
axum::http::HeaderValue::from_static(STATIC_CSP),
))
.layer(TraceLayer::new_for_http());
- Ok(BuiltApp { app, cleanup_task })
+ // ----- Admin (localhost-only) router -----------------------------------
+ // Shares `mev_state` with the public router so `/metrics` reflects live
+ // counters incremented by request handling. We deliberately skip the
+ // JSON-RPC timeout, body-limit, and rate-limit middlewares — those are
+ // designed for adversarial inputs and add no value on a localhost
+ // operator surface. Security headers stay on for defense in depth.
+ let admin_app = Router::new()
+ .route("/health", get(health_check))
+ .route("/metrics", get(security_metrics))
+ .with_state(mev_state)
+ .layer(middleware::from_fn(security_headers_middleware))
+ .layer(TraceLayer::new_for_http());
+
+ Ok(BuiltApp {
+ app,
+ admin_app,
+ cleanup_task,
+ })
}
fn env_u64(name: &str, default: u64) -> u64 {
diff --git a/src/error.rs b/src/error.rs
index 1585dc1..fc1622e 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -11,31 +11,31 @@ use crate::rpc_types::JsonRpcResponse;
pub enum ProxyError {
#[error("Invalid JSON-RPC request: {0}")]
InvalidRequest(String),
-
+
#[error("Method not allowed: {0}")]
MethodNotAllowed(String),
-
+
#[error("Rate limit exceeded")]
RateLimitExceeded,
-
+
#[error("Upstream connection error: {0}")]
UpstreamError(String),
-
+
#[error("JSON parsing error: {0}")]
JsonError(String),
-
+
#[error("HTTP request error: {0}")]
HttpError(String),
-
+
#[error("Internal server error: {0}")]
InternalError(String),
-
+
#[error("MEV relay error: {0}")]
MevRelayError(String),
-
+
#[error("Bundle simulation failed: {0}")]
BundleSimulationError(String),
-
+
#[error("Flashbots authentication failed")]
FlashbotsAuthError,
}
@@ -44,27 +44,22 @@ impl ProxyError {
/// Convert to JSON-RPC error code
pub fn to_json_rpc_code(&self) -> i64 {
match self {
- ProxyError::InvalidRequest(_) => -32600, // Invalid Request
+ ProxyError::InvalidRequest(_) => -32600, // Invalid Request
ProxyError::MethodNotAllowed(_) => -32601, // Method not found
- ProxyError::RateLimitExceeded => -32000, // Server error
- ProxyError::JsonError(_) => -32700, // Parse error
- ProxyError::UpstreamError(_) => -32001, // Server error
- ProxyError::HttpError(_) => -32002, // Server error
- ProxyError::InternalError(_) => -32603, // Internal error
- ProxyError::MevRelayError(_) => -32003, // MEV relay error
+ ProxyError::RateLimitExceeded => -32000, // Server error
+ ProxyError::JsonError(_) => -32700, // Parse error
+ ProxyError::UpstreamError(_) => -32001, // Server error
+ ProxyError::HttpError(_) => -32002, // Server error
+ ProxyError::InternalError(_) => -32603, // Internal error
+ ProxyError::MevRelayError(_) => -32003, // MEV relay error
ProxyError::BundleSimulationError(_) => -32004, // Bundle simulation error
- ProxyError::FlashbotsAuthError => -32005, // Authentication error
+ ProxyError::FlashbotsAuthError => -32005, // Authentication error
}
}
-
+
/// Convert to JSON-RPC error response
pub fn to_json_rpc_response(&self, id: Option) -> JsonRpcResponse {
- JsonRpcResponse::error(
- id,
- self.to_json_rpc_code(),
- self.to_string(),
- None,
- )
+ JsonRpcResponse::error(id, self.to_json_rpc_code(), self.to_string(), None)
}
}
@@ -77,9 +72,9 @@ impl IntoResponse for ProxyError {
ProxyError::JsonError(_) => StatusCode::BAD_REQUEST,
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
-
+
let error_response = self.to_json_rpc_response(None);
-
+
(status_code, Json(error_response)).into_response()
}
}
@@ -113,10 +108,7 @@ mod tests {
ProxyError::MethodNotAllowed("eth_accounts".to_string()).to_json_rpc_code(),
-32601
);
- assert_eq!(
- ProxyError::RateLimitExceeded.to_json_rpc_code(),
- -32000
- );
+ assert_eq!(ProxyError::RateLimitExceeded.to_json_rpc_code(), -32000);
assert_eq!(
ProxyError::JsonError("Parse error".to_string()).to_json_rpc_code(),
-32700
@@ -127,14 +119,13 @@ mod tests {
fn test_error_to_json_rpc_response() {
let error = ProxyError::MethodNotAllowed("eth_accounts".to_string());
let response = error.to_json_rpc_response(Some(json!(1)));
-
+
assert_eq!(response.jsonrpc, "2.0");
assert!(response.result.is_none());
assert!(response.error.is_some());
-
+
let rpc_error = response.error.unwrap();
assert_eq!(rpc_error.code, -32601);
assert_eq!(rpc_error.message, "Method not allowed: eth_accounts");
}
-
-}
\ No newline at end of file
+}
diff --git a/src/geth_client.rs b/src/geth_client.rs
index 63dce8b..fbcc962 100644
--- a/src/geth_client.rs
+++ b/src/geth_client.rs
@@ -1,6 +1,6 @@
+use anyhow::Result;
use reqwest::Client;
use serde_json::json;
-use anyhow::Result;
use crate::rpc_types::{JsonRpcRequest, JsonRpcResponse};
@@ -16,10 +16,10 @@ impl GethClient {
.timeout(std::time::Duration::from_secs(10))
.build()
.expect("Failed to create HTTP client");
-
+
Self { client, url }
}
-
+
/// Test basic connectivity by calling eth_blockNumber
pub async fn test_connectivity(&self) -> Result {
let request = JsonRpcRequest {
@@ -28,30 +28,26 @@ impl GethClient {
params: None,
id: Some(json!(1)),
};
-
- let response = self.client
- .post(&self.url)
- .json(&request)
- .send()
- .await?;
-
+
+ let response = self.client.post(&self.url).json(&request).send().await?;
+
if !response.status().is_success() {
anyhow::bail!("Geth returned status: {}", response.status());
}
-
+
let json_response: JsonRpcResponse = response.json().await?;
-
+
if let Some(error) = json_response.error {
anyhow::bail!("RPC error: {}", error.message);
}
-
+
if let Some(result) = json_response.result {
Ok(result.to_string())
} else {
anyhow::bail!("No result in response");
}
}
-
+
/// Get the chain ID
pub async fn get_chain_id(&self) -> Result {
let request = JsonRpcRequest {
@@ -60,15 +56,11 @@ impl GethClient {
params: None,
id: Some(json!(1)),
};
-
- let response = self.client
- .post(&self.url)
- .json(&request)
- .send()
- .await?;
-
+
+ let response = self.client.post(&self.url).json(&request).send().await?;
+
let json_response: JsonRpcResponse = response.json().await?;
-
+
if let Some(result) = json_response.result {
Ok(result.to_string())
} else {
@@ -81,45 +73,50 @@ impl GethClient {
mod tests {
use super::*;
use mockito::Server;
-
+
#[tokio::test]
async fn test_connectivity_success() {
let mut server = Server::new_async().await;
- let _m = server.mock("POST", "/")
+ let _m = server
+ .mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"jsonrpc":"2.0","result":"0x123","id":1}"#)
.create();
-
+
let client = GethClient::new(server.url());
let result = client.test_connectivity().await.unwrap();
assert_eq!(result, "\"0x123\"");
}
-
+
#[tokio::test]
async fn test_connectivity_error() {
let mut server = Server::new_async().await;
- let _m = server.mock("POST", "/")
+ let _m = server
+ .mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
- .with_body(r#"{"jsonrpc":"2.0","error":{"code":-32601,"message":"Method not found"},"id":1}"#)
+ .with_body(
+ r#"{"jsonrpc":"2.0","error":{"code":-32601,"message":"Method not found"},"id":1}"#,
+ )
.create();
-
+
let client = GethClient::new(server.url());
let result = client.test_connectivity().await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Method not found"));
}
-
+
#[tokio::test]
async fn test_get_chain_id() {
let mut server = Server::new_async().await;
- let _m = server.mock("POST", "/")
+ let _m = server
+ .mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"jsonrpc":"2.0","result":"0x539","id":1}"#)
.create();
-
+
let client = GethClient::new(server.url());
let result = client.get_chain_id().await.unwrap();
assert_eq!(result, "\"0x539\""); // 1337 in hex (dev chain ID)
@@ -130,14 +127,13 @@ mod tests {
#[cfg(not(test))]
pub async fn test_geth_connection() -> Result<()> {
use tracing::info;
-
- let url = std::env::var("GETH_URL")
- .unwrap_or_else(|_| "http://127.0.0.1:8545".to_string());
-
+
+ let url = std::env::var("GETH_URL").unwrap_or_else(|_| "http://127.0.0.1:8545".to_string());
+
info!("Testing Geth connection at: {}", url);
-
+
let client = GethClient::new(url);
-
+
// Test connectivity
match client.test_connectivity().await {
Ok(block_number) => {
@@ -147,7 +143,7 @@ pub async fn test_geth_connection() -> Result<()> {
anyhow::bail!("❌ Failed to connect to Geth: {}", e);
}
}
-
+
// Get chain ID
match client.get_chain_id().await {
Ok(chain_id) => {
@@ -157,6 +153,6 @@ pub async fn test_geth_connection() -> Result<()> {
anyhow::bail!("Failed to get chain ID: {}", e);
}
}
-
+
Ok(())
-}
\ No newline at end of file
+}
diff --git a/src/lib.rs b/src/lib.rs
index 7dc70cd..b52effb 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -7,4 +7,4 @@ pub mod rate_limit;
pub mod rpc_types;
pub mod security;
pub mod tor;
-pub mod whitelist;
\ No newline at end of file
+pub mod whitelist;
diff --git a/src/main.rs b/src/main.rs
index 4c4f1d3..5f132fc 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,15 +1,16 @@
//! ToRPC daemon entry point.
//!
-//! Almost all logic lives in `torpc::app::build_app`. This file is just the
-//! thin shell that parses env, builds the production router, binds the
-//! socket, and serves with graceful shutdown. Keeping it small means
-//! integration tests (`tests/daemon_e2e_test.rs`) can drive the same
-//! `build_app` directly without re-implementing layer ordering.
+//! Almost all logic lives in `torpc::app::build_app`. This file is the
+//! thin shell that parses env, builds the production routers, binds two
+//! sockets (public Tor-facing + admin localhost), and serves both with a
+//! shared graceful shutdown. Keeping it small means integration tests
+//! (`tests/daemon_e2e_test.rs`) can drive the same `build_app` directly
+//! without re-implementing layer ordering.
use std::net::SocketAddr;
use anyhow::Context;
-use tracing::info;
+use tracing::{debug, info, warn};
use tracing_subscriber::EnvFilter;
use torpc::app::{build_app, AppConfig};
@@ -49,55 +50,110 @@ async fn main() -> anyhow::Result<()> {
.with_target(false)
.init();
+ // Load `.env` if present. Operators who used to do `cp .env.example .env`
+ // and then `cargo run` previously had no env vars applied — the daemon
+ // never sourced the file. `dotenvy` is a no-op when the file is absent,
+ // so this is safe in systemd / docker setups that inject env directly.
+ let _ = dotenvy::dotenv();
+
info!("Starting TorPC proxy server");
let config = AppConfig::from_env();
let bind_addr = config.bind_addr.clone();
+ let admin_bind_addr = config.admin_bind_addr.clone();
let built = build_app(config).await?;
- // Tor advisory output (best-effort; never blocks startup).
+ // Tor configuration check. When `configs/torrc` is present, anonymity-
+ // disabling flags now fail the daemon hard (set TORPC_ALLOW_NON_ANONYMOUS=1
+ // to override for benchmarks/CI). When torrc is absent, this is a no-op.
let tor_service = TorService::new();
- if let Err(e) = tor_service.check_configuration() {
- info!("Tor configuration issue: {}", e);
- info!("Run ./scripts/setup-tor.sh to configure Tor");
- } else {
- match tor_service.get_hostname() {
- Ok(Some(hostname)) => {
- info!("🧅 Tor hidden service available at: http://{}", hostname);
- info!(" RPC endpoint: http://{}/rpc", hostname);
- info!(" Flashbots endpoint: http://{}/rpc/flashbots", hostname);
- }
- Ok(None) => {
- info!("Tor is configured but not running yet");
- info!("Start Tor with: ./scripts/start-tor.sh");
- }
- Err(e) => info!("Could not read Tor hostname: {}", e),
+ tor_service
+ .check_configuration()
+ .context("Tor configuration check failed; see error above for the offending line")?;
+ match tor_service.get_hostname() {
+ // The .onion hostname is the operator's public-facing identity. At
+ // INFO level it shows up in journalctl / syslog / log shippers,
+ // which makes accidental disclosure surprisingly easy. Keep it
+ // behind RUST_LOG=debug; operators who want it can `cat
+ // data/tor/torpc/hostname`.
+ Ok(Some(_)) => {
+ debug!("Tor hidden service hostname resolved (cat data/tor/torpc/hostname to view)")
}
+ Ok(None) => info!("Tor is configured but not running yet"),
+ Err(e) => warn!("Could not read Tor hostname: {}", e),
}
- let addr: SocketAddr = bind_addr
+ let public_addr: SocketAddr = bind_addr
+ .parse()
+ .with_context(|| format!("invalid BIND_ADDR: {bind_addr}"))?;
+ let admin_addr: SocketAddr = admin_bind_addr
.parse()
- .with_context(|| format!("invalid BIND_ADDR: {}", bind_addr))?;
- info!("Server listening on {}", addr);
- info!("Access the web interface at http://{}", addr);
+ .with_context(|| format!("invalid ADMIN_BIND_ADDR: {admin_bind_addr}"))?;
+
+ // Defense in depth: refuse to serve admin endpoints on a non-loopback
+ // address. Operators wanting remote scrape should reverse-proxy
+ // through their own auth or use an SSH tunnel.
+ if !admin_addr.ip().is_loopback() {
+ anyhow::bail!(
+ "ADMIN_BIND_ADDR ({admin_addr}) must be a loopback address; \
+ /health and /metrics expose operator state and are not safe \
+ on a public interface"
+ );
+ }
- let listener = tokio::net::TcpListener::bind(addr)
+ let public_listener = tokio::net::TcpListener::bind(public_addr)
.await
- .with_context(|| format!("failed to bind {}", addr))?;
-
- axum::serve(
- listener,
- built
- .app
- .into_make_service_with_connect_info::(),
- )
- .with_graceful_shutdown(shutdown_signal())
- .await
- .context("server task failed")?;
+ .with_context(|| format!("failed to bind {public_addr}"))?;
+ let admin_listener = tokio::net::TcpListener::bind(admin_addr)
+ .await
+ .with_context(|| format!("failed to bind {admin_addr}"))?;
+
+ info!("Public server listening on {} (Tor-facing)", public_addr);
+ info!(
+ "Admin server listening on {} (localhost-only: /health, /metrics)",
+ admin_addr
+ );
+ info!("Access the web interface at http://{}", public_addr);
+
+ // Both serve loops watch the same shutdown channel. When SIGINT/SIGTERM
+ // fires, we send once and both loops complete their graceful shutdown.
+ // tokio::sync::broadcast is enough for this two-receiver case and avoids
+ // pulling in `tokio-util::sync::CancellationToken` for a single use.
+ let (shutdown_tx, _) = tokio::sync::broadcast::channel::<()>(1);
+
+ let mut public_rx = shutdown_tx.subscribe();
+ let public_app = built.app;
+ let public_handle = tokio::spawn(async move {
+ axum::serve(
+ public_listener,
+ public_app.into_make_service_with_connect_info::(),
+ )
+ .with_graceful_shutdown(async move {
+ let _ = public_rx.recv().await;
+ })
+ .await
+ });
+
+ let mut admin_rx = shutdown_tx.subscribe();
+ let admin_app = built.admin_app;
+ let admin_handle = tokio::spawn(async move {
+ axum::serve(admin_listener, admin_app.into_make_service())
+ .with_graceful_shutdown(async move {
+ let _ = admin_rx.recv().await;
+ })
+ .await
+ });
+
+ shutdown_signal().await;
+ let _ = shutdown_tx.send(());
// Stop the rate-limiter cleanup task so the runtime exits cleanly.
built.cleanup_task.abort();
+ let (public_res, admin_res) = tokio::join!(public_handle, admin_handle);
+ public_res.context("public server task panicked")??;
+ admin_res.context("admin server task panicked")??;
+
info!("Server stopped cleanly");
Ok(())
}
diff --git a/src/mev/auth.rs b/src/mev/auth.rs
index ef7f2e0..ac99f80 100644
--- a/src/mev/auth.rs
+++ b/src/mev/auth.rs
@@ -1,5 +1,5 @@
//! Flashbots authentication using EIP-191 signatures
-//!
+//!
//! This module implements the X-Flashbots-Signature authentication scheme
//! required for submitting bundles to Flashbots relays.
@@ -14,26 +14,26 @@ pub enum AuthError {
/// Invalid private key format
#[error("Invalid private key: {0}")]
InvalidKey(String),
-
+
/// Signature generation failed
#[error("Failed to sign message: {0}")]
SigningError(String),
-
+
/// Hex encoding/decoding error
#[error("Hex encoding error: {0}")]
HexError(#[from] hex::FromHexError),
}
/// Handles Flashbots authentication using EIP-191 signatures
-///
+///
/// # Security Note
/// The signing key is used only for authentication, not for transactions.
/// Any Ethereum key can be used - it doesn't need to hold funds.
-///
+///
/// # For Developers
/// The authenticator signs the entire JSON-RPC request body to prove
/// the request hasn't been tampered with in transit.
-///
+///
/// # Example
/// ```rust
/// let auth = FlashbotsAuthenticator::new("your-private-key-hex")?;
@@ -53,89 +53,93 @@ pub struct FlashbotsAuthenticator {
impl FlashbotsAuthenticator {
/// Create a new authenticator with the given private key
- ///
+ ///
/// # Arguments
/// * `private_key_hex` - Private key in hex format (with or without 0x prefix)
- ///
+ ///
/// # Returns
/// * `Ok(FlashbotsAuthenticator)` - Configured authenticator
/// * `Err(AuthError)` - If the private key is invalid
pub fn new(private_key_hex: &str) -> Result {
let secp = Arc::new(Secp256k1::new());
-
+
// Remove 0x prefix if present
- let key_hex = private_key_hex.strip_prefix("0x").unwrap_or(private_key_hex);
-
+ let key_hex = private_key_hex
+ .strip_prefix("0x")
+ .unwrap_or(private_key_hex);
+
// Parse private key
let key_bytes = hex::decode(key_hex)?;
- let signing_key = SecretKey::from_slice(&key_bytes)
- .map_err(|e| AuthError::InvalidKey(e.to_string()))?;
-
+ let signing_key =
+ SecretKey::from_slice(&key_bytes).map_err(|e| AuthError::InvalidKey(e.to_string()))?;
+
// Derive public key and address
let public_key = signing_key.public_key(&secp);
let public_key_bytes = public_key.serialize_uncompressed();
-
+
// Ethereum address is last 20 bytes of keccak256(public_key)
let mut hasher = Keccak256::new();
hasher.update(&public_key_bytes[1..]); // Skip the 0x04 prefix
let hash = hasher.finalize();
let address_bytes = &hash[12..]; // Last 20 bytes
let signer_address = format!("0x{}", hex::encode(address_bytes));
-
+
Ok(Self {
secp,
signing_key,
signer_address,
})
}
-
+
/// Get the signer's Ethereum address
- ///
+ ///
/// # Returns
/// The 0x-prefixed Ethereum address derived from the signing key
pub fn address(&self) -> &str {
&self.signer_address
}
-
+
/// Sign a request body and return the X-Flashbots-Signature header value
- ///
+ ///
/// # Format
/// Returns: "0xAddress:0xSignature"
- ///
+ ///
/// # Example
/// ```rust
/// let body = r#"{"jsonrpc":"2.0","method":"eth_sendBundle",...}"#;
/// let signature = auth.sign_request(body)?;
/// // Returns: "0x1234...abcd:0x5678...ef01"
/// ```
- ///
+ ///
/// # Arguments
/// * `body` - The complete JSON-RPC request body to sign
- ///
+ ///
/// # Returns
/// * `Ok(String)` - The formatted signature header value
/// * `Err(AuthError)` - If signing fails
pub fn sign_request(&self, body: &str) -> Result {
// EIP-191 personal message format
let message_to_sign = self.create_eip191_message(body);
-
+
// Hash the message
let mut hasher = Keccak256::new();
hasher.update(&message_to_sign);
let hash = hasher.finalize();
-
+
// Sign the hash
- let message = Message::from_slice(&hash)
- .map_err(|e| AuthError::SigningError(e.to_string()))?;
-
- let signature = self.secp.sign_ecdsa_recoverable(&message, &self.signing_key);
+ let message =
+ Message::from_slice(&hash).map_err(|e| AuthError::SigningError(e.to_string()))?;
+
+ let signature = self
+ .secp
+ .sign_ecdsa_recoverable(&message, &self.signing_key);
let (recovery_id, signature_bytes) = signature.serialize_compact();
// Format signature as Ethereum does (v = recovery_id + 27)
let mut eth_signature = [0u8; 65];
eth_signature[..64].copy_from_slice(&signature_bytes);
eth_signature[64] = recovery_id.to_i32() as u8 + 27;
-
+
// Format as "0xAddress:0xSignature"
Ok(format!(
"{}:0x{}",
@@ -143,9 +147,9 @@ impl FlashbotsAuthenticator {
hex::encode(eth_signature)
))
}
-
+
/// Create an EIP-191 formatted message
- ///
+ ///
/// # Format
/// "\x19Ethereum Signed Message:\n"
fn create_eip191_message(&self, body: &str) -> Vec {
@@ -159,18 +163,18 @@ impl FlashbotsAuthenticator {
#[cfg(test)]
mod tests {
use super::*;
-
+
#[test]
fn test_authenticator_creation() {
// Test key (DO NOT USE IN PRODUCTION)
let key = "1111111111111111111111111111111111111111111111111111111111111111";
let auth = FlashbotsAuthenticator::new(key).unwrap();
-
+
// Verify address derivation
assert!(auth.address().starts_with("0x"));
assert_eq!(auth.address().len(), 42); // 0x + 40 hex chars
}
-
+
#[test]
fn test_authenticator_with_0x_prefix() {
// Test that 0x prefix is handled correctly
@@ -178,15 +182,15 @@ mod tests {
let auth = FlashbotsAuthenticator::new(key).unwrap();
assert!(auth.address().starts_with("0x"));
}
-
+
#[test]
fn test_signature_format() {
let key = "1111111111111111111111111111111111111111111111111111111111111111";
let auth = FlashbotsAuthenticator::new(key).unwrap();
-
+
let body = r#"{"jsonrpc":"2.0","method":"eth_sendBundle","params":[],"id":1}"#;
let signature = auth.sign_request(body).unwrap();
-
+
// Verify format: address:signature
let parts: Vec<&str> = signature.split(':').collect();
assert_eq!(parts.len(), 2);
@@ -194,16 +198,16 @@ mod tests {
assert!(parts[1].starts_with("0x"));
assert_eq!(parts[1].len(), 132); // 0x + 65 bytes * 2 hex chars
}
-
+
#[test]
fn test_invalid_key() {
let result = FlashbotsAuthenticator::new("invalid-hex");
assert!(result.is_err());
-
+
let result = FlashbotsAuthenticator::new("00"); // Too short
assert!(result.is_err());
}
-
+
#[test]
fn test_deterministic_signatures() {
let key = "1111111111111111111111111111111111111111111111111111111111111111";
@@ -236,7 +240,11 @@ mod tests {
let header = auth.sign_request(body).unwrap();
let (addr, sig_hex) = header.split_once(':').expect("address:signature header");
let sig_bytes = hex::decode(sig_hex.trim_start_matches("0x")).unwrap();
- assert_eq!(sig_bytes.len(), 65, "Flashbots requires 65-byte EIP-191 signature");
+ assert_eq!(
+ sig_bytes.len(),
+ 65,
+ "Flashbots requires 65-byte EIP-191 signature"
+ );
// Reconstruct the same EIP-191 hash the authenticator signed
let prefix = format!("\x19Ethereum Signed Message:\n{}", body.len());
@@ -265,4 +273,4 @@ mod tests {
assert_eq!(recovered, addr, "recovered address must match signer");
assert_eq!(recovered, auth.address());
}
-}
\ No newline at end of file
+}
diff --git a/src/mev/client.rs b/src/mev/client.rs
index 81bb896..85c53cf 100644
--- a/src/mev/client.rs
+++ b/src/mev/client.rs
@@ -1,30 +1,30 @@
//! MEV relay client for submitting bundles to Flashbots
-//!
+//!
//! This module provides the main client interface for MEV protection,
//! handling bundle submission, authentication, and retry logic.
-use std::sync::Arc;
-use std::time::Duration;
use reqwest::{Client, StatusCode};
use serde_json::Value;
+use std::sync::Arc;
+use std::time::Duration;
use tracing::{debug, info};
-use crate::error::ProxyError;
-use crate::rpc_types::JsonRpcRequest;
use super::auth::FlashbotsAuthenticator;
use super::retry::{CircuitBreaker, RetryPolicy};
use super::types::{Bundle, MevConfig, SendBundleRequest};
+use crate::error::ProxyError;
+use crate::rpc_types::JsonRpcRequest;
/// MEV relay client for submitting bundles to Flashbots
-///
+///
/// Handles connection pooling, authentication, and retry logic for
/// reliable bundle submission to MEV relays.
-///
+///
/// # For Proxy Operators
/// Configure via environment variables:
/// - `FLASHBOTS_RELAY_URL`: Relay endpoint (defaults to mainnet)
/// - `FLASHBOTS_SIGNING_KEY`: Private key for authentication
-///
+///
/// # Example
/// ```rust
/// let config = MevConfig {
@@ -32,7 +32,7 @@ use super::types::{Bundle, MevConfig, SendBundleRequest};
/// signing_key: "your-private-key".to_string(),
/// ..Default::default()
/// };
-///
+///
/// let client = MevRelayClient::new(config)?;
/// let bundle_hash = client.send_bundle(bundle).await?;
/// ```
@@ -51,10 +51,10 @@ pub struct MevRelayClient {
impl MevRelayClient {
/// Create a new MEV relay client
- ///
+ ///
/// # Arguments
/// * `config` - MEV relay configuration
- ///
+ ///
/// # Returns
/// * `Ok(MevRelayClient)` - Configured client
/// * `Err(ProxyError)` - If configuration is invalid
@@ -65,18 +65,18 @@ impl MevRelayClient {
.pool_idle_timeout(Duration::from_secs(90))
.pool_max_idle_per_host(10)
.build()
- .map_err(|e| ProxyError::InternalError(format!("Failed to create HTTP client: {}", e)))?;
-
+ .map_err(|e| ProxyError::InternalError(format!("Failed to create HTTP client: {e}")))?;
+
// Create authenticator
let authenticator = FlashbotsAuthenticator::new(&config.signing_key)
- .map_err(|e| ProxyError::InternalError(format!("Invalid signing key: {}", e)))?;
-
+ .map_err(|e| ProxyError::InternalError(format!("Invalid signing key: {e}")))?;
+
info!(
"MEV client initialized with relay: {} and signer: {}",
config.relay_url,
authenticator.address()
);
-
+
Ok(Self {
http_client,
config,
@@ -85,37 +85,37 @@ impl MevRelayClient {
retry_policy: RetryPolicy::default(),
})
}
-
+
/// Transform a raw transaction into a bundle for the next block
- ///
+ ///
/// # For Library Developers
/// Converts eth_sendRawTransaction format to eth_sendBundle format
/// targeting the next block for inclusion.
- ///
+ ///
/// # Arguments
/// * `raw_tx` - Signed transaction in hex format
/// * `current_block` - Current block number
- ///
+ ///
/// # Returns
/// Bundle configured for next block submission
pub fn create_bundle_from_tx(&self, raw_tx: String, current_block: u64) -> Bundle {
let target_block = current_block + self.config.blocks_ahead;
-
+
Bundle {
txs: vec![raw_tx],
- block_number: format!("0x{:x}", target_block),
+ block_number: format!("0x{target_block:x}"),
min_timestamp: None,
max_timestamp: None,
}
}
-
+
/// Submit a bundle to the MEV relay
- ///
+ ///
/// Handles authentication, retry logic, and circuit breaking.
- ///
+ ///
/// # Arguments
/// * `bundle` - Bundle to submit
- ///
+ ///
/// # Returns
/// * `Ok(bundle_hash)` - Unique identifier for tracking
/// * `Err(ProxyError)` - If submission fails
@@ -123,34 +123,42 @@ impl MevRelayClient {
// Check circuit breaker
if !self.circuit_breaker.can_proceed().await {
return Err(ProxyError::UpstreamError(
- "MEV relay circuit breaker is open - too many failures".to_string()
+ "MEV relay circuit breaker is open - too many failures".to_string(),
));
}
-
+
// Create request
let request = SendBundleRequest::new(bundle.clone(), 1);
let body = serde_json::to_string(&request)
- .map_err(|e| ProxyError::InternalError(format!("Failed to serialize bundle: {}", e)))?;
-
+ .map_err(|e| ProxyError::InternalError(format!("Failed to serialize bundle: {e}")))?;
+
// Attempt with retries
let mut last_error = None;
-
+
for attempt in 0..self.retry_policy.max_attempts {
if attempt > 0 {
let delay = self.retry_policy.calculate_delay(attempt - 1);
- debug!("Retrying bundle submission after {:?} (attempt {})", delay, attempt + 1);
+ debug!(
+ "Retrying bundle submission after {:?} (attempt {})",
+ delay,
+ attempt + 1
+ );
tokio::time::sleep(delay).await;
}
-
+
match self.send_bundle_attempt(&body).await {
Ok(bundle_hash) => {
self.circuit_breaker.record_success().await;
- info!("Bundle submitted successfully: {}", bundle_hash);
+ // Bundle hash is a correlatable identifier for a real
+ // user's transaction. INFO-level logs flow into syslog /
+ // log shippers by default; downgrade so operators have to
+ // opt in via RUST_LOG=debug.
+ debug!("Bundle submitted successfully: {}", bundle_hash);
return Ok(bundle_hash);
}
Err(e) => {
last_error = Some(e);
-
+
// Check if error is retryable
if let Some(ref error) = last_error {
if !self.retry_policy.should_retry(&error.to_string()) {
@@ -161,38 +169,43 @@ impl MevRelayClient {
}
}
}
-
+
// All retries exhausted
self.circuit_breaker.record_failure().await;
- Err(last_error.unwrap_or_else(||
+ Err(last_error.unwrap_or_else(|| {
ProxyError::UpstreamError("Bundle submission failed after all retries".to_string())
- ))
+ }))
}
-
+
/// Single attempt to send a bundle
async fn send_bundle_attempt(&self, body: &str) -> Result {
// Sign the request
- let signature = self.authenticator.sign_request(body)
- .map_err(|e| ProxyError::InternalError(format!("Failed to sign request: {}", e)))?;
-
+ let signature = self
+ .authenticator
+ .sign_request(body)
+ .map_err(|e| ProxyError::InternalError(format!("Failed to sign request: {e}")))?;
+
debug!("Sending bundle to {} with signature", self.config.relay_url);
-
+
// Send request
- let response = self.http_client
+ let response = self
+ .http_client
.post(&self.config.relay_url)
.header("Content-Type", "application/json")
.header("X-Flashbots-Signature", signature)
.body(body.to_string())
.send()
.await
- .map_err(|e| ProxyError::UpstreamError(format!("Network error: {}", e)))?;
-
+ .map_err(|e| ProxyError::UpstreamError(format!("Network error: {e}")))?;
+
let status = response.status();
- let response_text = response.text().await
+ let response_text = response
+ .text()
+ .await
.unwrap_or_else(|_| "Failed to read response body".to_string());
-
+
debug!("Relay response: {} - {}", status, response_text);
-
+
// Parse response
if status.is_success() {
self.parse_bundle_response(&response_text)
@@ -200,67 +213,67 @@ impl MevRelayClient {
Err(self.map_relay_error(status, &response_text))
}
}
-
+
/// Parse successful bundle response
fn parse_bundle_response(&self, response_text: &str) -> Result {
- let response: Value = serde_json::from_str(response_text)
- .map_err(|e| ProxyError::UpstreamError(
- format!("Invalid JSON response from relay: {}", e)
- ))?;
-
+ let response: Value = serde_json::from_str(response_text).map_err(|e| {
+ ProxyError::UpstreamError(format!("Invalid JSON response from relay: {e}"))
+ })?;
+
// Handle JSON-RPC response format
if let Some(error) = response.get("error") {
- let error_msg = error.get("message")
+ let error_msg = error
+ .get("message")
.and_then(|m| m.as_str())
.unwrap_or("Unknown error");
-
- return Err(ProxyError::UpstreamError(
- format!("Relay error: {}", error_msg)
- ));
+
+ return Err(ProxyError::UpstreamError(format!(
+ "Relay error: {error_msg}"
+ )));
}
-
+
// Extract bundle hash from result
let bundle_hash = response
.get("result")
.and_then(|r| r.get("bundleHash"))
.and_then(|h| h.as_str())
- .ok_or_else(|| ProxyError::UpstreamError(
- "Missing bundleHash in response".to_string()
- ))?;
-
+ .ok_or_else(|| {
+ ProxyError::UpstreamError("Missing bundleHash in response".to_string())
+ })?;
+
Ok(bundle_hash.to_string())
}
-
+
/// Map relay HTTP errors to ProxyError
fn map_relay_error(&self, status: StatusCode, body: &str) -> ProxyError {
match status {
StatusCode::UNAUTHORIZED => ProxyError::InternalError(
- "Flashbots authentication failed - check signing key".to_string()
+ "Flashbots authentication failed - check signing key".to_string(),
),
StatusCode::BAD_REQUEST => {
// Try to extract error message
if let Ok(json) = serde_json::from_str::(body) {
- if let Some(error_msg) = json.get("error")
+ if let Some(error_msg) = json
+ .get("error")
.and_then(|e| e.get("message"))
- .and_then(|m| m.as_str()) {
- return ProxyError::InvalidRequest(
- format!("Bundle validation failed: {}", error_msg)
- );
+ .and_then(|m| m.as_str())
+ {
+ return ProxyError::InvalidRequest(format!(
+ "Bundle validation failed: {error_msg}"
+ ));
}
}
ProxyError::InvalidRequest("Invalid bundle format".to_string())
}
- StatusCode::TOO_MANY_REQUESTS => ProxyError::UpstreamError(
- "MEV relay rate limit exceeded".to_string()
- ),
- _ => ProxyError::UpstreamError(
- format!("MEV relay error {}: {}", status, body)
- ),
+ StatusCode::TOO_MANY_REQUESTS => {
+ ProxyError::UpstreamError("MEV relay rate limit exceeded".to_string())
+ }
+ _ => ProxyError::UpstreamError(format!("MEV relay error {status}: {body}")),
}
}
-
+
/// Handle eth_sendRawTransaction by converting to bundle
- ///
+ ///
/// # For Library Developers
/// This is the main entry point from the proxy for MEV protection.
/// Transforms a regular transaction into a Flashbots bundle.
@@ -270,24 +283,28 @@ impl MevRelayClient {
current_block: u64,
) -> Result {
// Extract raw transaction from params
- let raw_tx = request.params
+ let raw_tx = request
+ .params
.as_ref()
.and_then(|p| p.as_array())
.and_then(|arr| arr.first())
.and_then(|v| v.as_str())
- .ok_or_else(|| ProxyError::InvalidRequest(
- "Missing transaction data in params".to_string()
- ))?;
-
+ .ok_or_else(|| {
+ ProxyError::InvalidRequest("Missing transaction data in params".to_string())
+ })?;
+
// Create and submit bundle
let bundle = self.create_bundle_from_tx(raw_tx.to_string(), current_block);
- info!("Converting transaction to bundle for block {}", bundle.block_number);
-
+ info!(
+ "Converting transaction to bundle for block {}",
+ bundle.block_number
+ );
+
self.send_bundle(bundle).await
}
-
+
/// Handle eth_sendBundle directly
- ///
+ ///
/// # For Library Developers
/// Processes pre-formatted bundle submissions from advanced users.
/// Non-blocking summary of the relay circuit-breaker state, used by
@@ -299,18 +316,15 @@ impl MevRelayClient {
pub async fn handle_send_bundle(&self, request: &JsonRpcRequest) -> Result {
// Extract bundle from params
- let bundle_json = request.params
+ let bundle_json = request
+ .params
.as_ref()
.and_then(|p| p.as_array())
.and_then(|arr| arr.first())
- .ok_or_else(|| ProxyError::InvalidRequest(
- "Missing bundle in params".to_string()
- ))?;
+ .ok_or_else(|| ProxyError::InvalidRequest("Missing bundle in params".to_string()))?;
let bundle: Bundle = serde_json::from_value(bundle_json.clone())
- .map_err(|e| ProxyError::InvalidRequest(
- format!("Invalid bundle format: {}", e)
- ))?;
+ .map_err(|e| ProxyError::InvalidRequest(format!("Invalid bundle format: {e}")))?;
self.send_bundle(bundle).await
}
@@ -324,67 +338,69 @@ pub fn create_mev_client(config: MevConfig) -> Result, Proxy
#[cfg(test)]
mod tests {
use super::*;
-
+
fn test_config() -> MevConfig {
MevConfig {
relay_url: "https://relay-sepolia.flashbots.net".to_string(),
- signing_key: "1111111111111111111111111111111111111111111111111111111111111111".to_string(),
+ signing_key: "1111111111111111111111111111111111111111111111111111111111111111"
+ .to_string(),
request_timeout: Duration::from_secs(5),
blocks_ahead: 1,
}
}
-
+
#[test]
fn test_bundle_creation() {
let config = test_config();
let client = MevRelayClient::new(config).unwrap();
-
+
let raw_tx = "0xf86b...".to_string();
let bundle = client.create_bundle_from_tx(raw_tx.clone(), 1000);
-
+
assert_eq!(bundle.txs.len(), 1);
assert_eq!(bundle.txs[0], raw_tx);
assert_eq!(bundle.block_number, "0x3e9"); // 1001 in hex
assert!(bundle.min_timestamp.is_none());
assert!(bundle.max_timestamp.is_none());
}
-
+
#[test]
fn test_parse_bundle_response() {
let config = test_config();
let client = MevRelayClient::new(config).unwrap();
-
+
// Success response
let response = r#"{"jsonrpc":"2.0","result":{"bundleHash":"0xabc123"},"id":1}"#;
let result = client.parse_bundle_response(response);
assert_eq!(result.unwrap(), "0xabc123");
-
+
// Error response
- let error_response = r#"{"jsonrpc":"2.0","error":{"code":-32000,"message":"Bundle failed"},"id":1}"#;
+ let error_response =
+ r#"{"jsonrpc":"2.0","error":{"code":-32000,"message":"Bundle failed"},"id":1}"#;
let result = client.parse_bundle_response(error_response);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Bundle failed"));
}
-
+
#[test]
fn test_map_relay_errors() {
let config = test_config();
let client = MevRelayClient::new(config).unwrap();
-
+
// Unauthorized
let error = client.map_relay_error(StatusCode::UNAUTHORIZED, "");
assert!(matches!(error, ProxyError::InternalError(_)));
assert!(error.to_string().contains("authentication"));
-
+
// Bad request with JSON error
let body = r#"{"error":{"message":"Invalid bundle"}}"#;
let error = client.map_relay_error(StatusCode::BAD_REQUEST, body);
assert!(matches!(error, ProxyError::InvalidRequest(_)));
assert!(error.to_string().contains("Invalid bundle"));
-
+
// Rate limit
let error = client.map_relay_error(StatusCode::TOO_MANY_REQUESTS, "");
assert!(matches!(error, ProxyError::UpstreamError(_)));
assert!(error.to_string().contains("rate limit"));
}
-}
\ No newline at end of file
+}
diff --git a/src/mev/mev_handler.rs b/src/mev/mev_handler.rs
index 64a4c16..3bf47d2 100644
--- a/src/mev/mev_handler.rs
+++ b/src/mev/mev_handler.rs
@@ -1,18 +1,18 @@
//! MEV request handler module
-//!
+//!
//! This module provides MEV-aware request handling that integrates
//! with the proxy module without circular dependency issues.
-use std::sync::Arc;
use axum::{extract::State, Json};
-use tracing::{info, warn};
+use std::sync::Arc;
+use tracing::{debug, warn};
+use super::client::MevRelayClient;
use crate::{
error::{ProxyError, ProxyResult},
- proxy::{ProxyState, proxy_to_geth},
+ proxy::{proxy_to_geth, ProxyState},
rpc_types::{JsonRpcRequest, JsonRpcResponse},
};
-use super::client::MevRelayClient;
/// MEV-aware state wrapper
pub struct MevProxyState {
@@ -44,20 +44,26 @@ pub async fn handle_flashbots_with_mev(
params: None,
id: Some(serde_json::json!(1)),
};
-
+
let block_resp = proxy_to_geth(&state.base_state, block_req).await?;
- let block_hex = block_resp.result
+ let block_hex = block_resp
+ .result
.as_ref()
.and_then(|v| v.as_str())
- .ok_or_else(|| ProxyError::InternalError("Failed to get block number".to_string()))?;
-
+ .ok_or_else(|| {
+ ProxyError::InternalError("Failed to get block number".to_string())
+ })?;
+
let current_block = u64::from_str_radix(block_hex.trim_start_matches("0x"), 16)
- .map_err(|e| ProxyError::InternalError(format!("Invalid block number: {}", e)))?;
-
- // Submit via MEV client
- info!("Submitting transaction via MEV relay");
- let bundle_hash = mev_client.handle_send_raw_transaction(&request, current_block).await?;
-
+ .map_err(|e| ProxyError::InternalError(format!("Invalid block number: {e}")))?;
+
+ // Submit via MEV client. Logged at debug to keep per-tx
+ // routing decisions out of default operator logs.
+ debug!("Submitting transaction via MEV relay");
+ let bundle_hash = mev_client
+ .handle_send_raw_transaction(&request, current_block)
+ .await?;
+
// Return bundle hash as if it were a transaction hash
Ok(Json(JsonRpcResponse {
jsonrpc: "2.0".to_string(),
@@ -68,9 +74,9 @@ pub async fn handle_flashbots_with_mev(
}
"eth_sendBundle" => {
// Direct bundle submission
- info!("Submitting bundle via MEV relay");
+ debug!("Submitting bundle via MEV relay");
let bundle_hash = mev_client.handle_send_bundle(&request).await?;
-
+
Ok(Json(JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: Some(serde_json::json!({
@@ -91,4 +97,4 @@ pub async fn handle_flashbots_with_mev(
warn!("MEV client not configured, using standard handler");
crate::proxy::handle_flashbots(State(state.base_state.clone()), Json(request)).await
}
-}
\ No newline at end of file
+}
diff --git a/src/mev/mod.rs b/src/mev/mod.rs
index a1d9bfc..2ab27a8 100644
--- a/src/mev/mod.rs
+++ b/src/mev/mod.rs
@@ -1,24 +1,24 @@
//! MEV (Maximum Extractable Value) protection module
-//!
+//!
//! This module provides integration with Flashbots and other MEV relay services
//! to protect users from sandwich attacks and provide private transaction submission.
-//!
+//!
//! # Overview
-//!
+//!
//! MEV protection works by submitting transactions directly to block builders
//! through private relays instead of the public mempool. This prevents:
//! - Front-running attacks
//! - Sandwich attacks
//! - Transaction censorship
-//!
+//!
//! # For Proxy Operators
-//!
+//!
//! To enable MEV protection, configure the following environment variables:
//! - `FLASHBOTS_RELAY_URL`: MEV relay endpoint (defaults to Flashbots mainnet)
//! - `FLASHBOTS_SIGNING_KEY`: Private key for authentication (any Ethereum key)
-//!
+//!
//! # Architecture
-//!
+//!
//! The module is organized as follows:
//! - `client` - Main MEV relay client with connection pooling
//! - `auth` - Flashbots authentication using EIP-191 signatures
@@ -34,4 +34,4 @@ pub mod types;
// Re-export main types for convenience
pub use auth::{AuthError, FlashbotsAuthenticator};
pub use client::{create_mev_client, MevRelayClient};
-pub use types::{Bundle, BundleResponse, MevConfig};
\ No newline at end of file
+pub use types::{Bundle, BundleResponse, MevConfig};
diff --git a/src/mev/retry.rs b/src/mev/retry.rs
index c3e5579..2ce2f0a 100644
--- a/src/mev/retry.rs
+++ b/src/mev/retry.rs
@@ -1,29 +1,29 @@
//! Retry logic and circuit breaker for MEV relay resilience
-//!
+//!
//! This module provides fault tolerance mechanisms to handle
//! transient failures and prevent cascading failures when
//! communicating with MEV relays.
+use rand::{thread_rng, Rng};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::Mutex;
-use rand::{thread_rng, Rng};
use tracing::{debug, warn};
use super::types::CircuitState;
/// Implements exponential backoff with jitter for retrying failed requests
-///
+///
/// # For Library Developers
/// Used internally by MevRelayClient to handle transient failures.
/// Not exposed in public API.
-///
+///
/// # Retry Strategy
/// - Initial delay: 100ms
/// - Max delay: 5s
/// - Jitter: ±25% to prevent thundering herd
/// - Max attempts: 3
-///
+///
/// # Example
/// ```rust
/// let policy = RetryPolicy::default();
@@ -63,30 +63,30 @@ impl Default for RetryPolicy {
impl RetryPolicy {
/// Calculate delay for a given attempt number with exponential backoff
- ///
+ ///
/// # Arguments
/// * `attempt` - Zero-based attempt number
- ///
+ ///
/// # Returns
/// Duration to wait before next attempt
pub fn calculate_delay(&self, attempt: u32) -> Duration {
// Exponential backoff: delay * 2^attempt
let base_delay = self.initial_delay.as_millis() as f64 * (2_u32.pow(attempt) as f64);
-
+
// Cap at max delay
let capped_delay = base_delay.min(self.max_delay.as_millis() as f64);
-
+
// Add jitter (±jitter_factor)
let mut rng = thread_rng();
let jitter_range = capped_delay * self.jitter_factor;
let jitter = rng.gen_range(-jitter_range..=jitter_range);
let final_delay = (capped_delay + jitter).max(0.0) as u64;
-
+
Duration::from_millis(final_delay)
}
-
+
/// Determine if an error is retryable
- ///
+ ///
/// # For Library Developers
/// Network errors and timeouts are retryable.
/// Authentication and validation errors are not.
@@ -95,45 +95,46 @@ impl RetryPolicy {
if error.contains("authentication") || error.contains("unauthorized") {
return false;
}
-
+
// Don't retry validation errors
if error.contains("invalid") || error.contains("malformed") {
return false;
}
-
+
// Retry network and timeout errors
- if error.contains("timeout") ||
- error.contains("connection") ||
- error.contains("network") ||
- error.contains("temporarily unavailable") {
+ if error.contains("timeout")
+ || error.contains("connection")
+ || error.contains("network")
+ || error.contains("temporarily unavailable")
+ {
return true;
}
-
+
// Default: retry on generic errors
true
}
}
/// Circuit breaker to prevent cascading failures
-///
+///
/// # For Library Developers
/// Tracks consecutive failures and temporarily disables requests
/// when a threshold is reached.
-///
+///
/// # State Transitions
/// - Closed -> Open: After 5 consecutive failures
/// - Open -> HalfOpen: After 30 seconds
/// - HalfOpen -> Closed: After 1 success
/// - HalfOpen -> Open: After 1 failure
-///
+///
/// # Example
/// ```rust
/// let breaker = CircuitBreaker::new();
-///
+///
/// if !breaker.can_proceed().await {
/// return Err("Circuit breaker open");
/// }
-///
+///
/// match make_request().await {
/// Ok(response) => {
/// breaker.record_success().await;
@@ -154,6 +155,12 @@ pub struct CircuitBreaker {
recovery_timeout: Duration,
}
+impl Default for CircuitBreaker {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
impl CircuitBreaker {
/// Create a new circuit breaker with default settings (threshold 5, recovery 30s).
pub fn new() -> Self {
@@ -167,7 +174,9 @@ impl CircuitBreaker {
/// * `recovery_timeout` - How long to remain Open before lazily flipping to HalfOpen.
pub fn with_config(failure_threshold: u32, recovery_timeout: Duration) -> Self {
Self {
- state: Arc::new(Mutex::new(CircuitState::Closed { consecutive_failures: 0 })),
+ state: Arc::new(Mutex::new(CircuitState::Closed {
+ consecutive_failures: 0,
+ })),
failure_threshold,
recovery_timeout,
}
@@ -204,12 +213,16 @@ impl CircuitBreaker {
match *state {
CircuitState::HalfOpen => {
debug!("Circuit breaker closing after successful recovery");
- *state = CircuitState::Closed { consecutive_failures: 0 };
+ *state = CircuitState::Closed {
+ consecutive_failures: 0,
+ };
}
// From Closed{n}, success resets the counter — we count *consecutive*
// failures, so a single success is enough to wipe the slate.
CircuitState::Closed { .. } => {
- *state = CircuitState::Closed { consecutive_failures: 0 };
+ *state = CircuitState::Closed {
+ consecutive_failures: 0,
+ };
}
// From Open we shouldn't normally see successes (can_proceed gates them
// off), but if one slips through (a request started before the breaker
@@ -225,18 +238,26 @@ impl CircuitBreaker {
let mut state = self.state.lock().await;
match *state {
- CircuitState::Closed { consecutive_failures } => {
+ CircuitState::Closed {
+ consecutive_failures,
+ } => {
let next = consecutive_failures + 1;
if next >= self.failure_threshold {
warn!("Circuit breaker opened after {} consecutive failures", next);
- *state = CircuitState::Open { opened_at: Instant::now() };
+ *state = CircuitState::Open {
+ opened_at: Instant::now(),
+ };
} else {
- *state = CircuitState::Closed { consecutive_failures: next };
+ *state = CircuitState::Closed {
+ consecutive_failures: next,
+ };
}
}
CircuitState::HalfOpen => {
warn!("Circuit breaker reopening after failed recovery attempt");
- *state = CircuitState::Open { opened_at: Instant::now() };
+ *state = CircuitState::Open {
+ opened_at: Instant::now(),
+ };
}
// Already Open — additional failures don't change the open timestamp;
// the recovery timeout still measures from when we first opened.
@@ -265,7 +286,7 @@ impl CircuitBreaker {
}
/// Tracks consecutive failures for circuit breaker logic
-///
+///
/// # For Library Developers
/// This is a simpler alternative implementation that just tracks
/// consecutive failures without the full state machine.
@@ -281,17 +302,17 @@ impl ConsecutiveFailureTracker {
threshold,
}
}
-
+
pub async fn record_success(&self) {
*self.failures.lock().await = 0;
}
-
+
pub async fn record_failure(&self) -> bool {
let mut failures = self.failures.lock().await;
*failures += 1;
*failures >= self.threshold
}
-
+
pub async fn should_open(&self) -> bool {
*self.failures.lock().await >= self.threshold
}
@@ -300,78 +321,80 @@ impl ConsecutiveFailureTracker {
#[cfg(test)]
mod tests {
use super::*;
-
+
#[test]
fn test_retry_delay_calculation() {
let policy = RetryPolicy::default();
-
+
// Test exponential backoff
let delay0 = policy.calculate_delay(0);
let delay1 = policy.calculate_delay(1);
let delay2 = policy.calculate_delay(2);
-
+
// Each delay should be roughly double the previous (minus jitter)
- assert!(delay0.as_millis() >= 75); // 100ms - 25%
+ assert!(delay0.as_millis() >= 75); // 100ms - 25%
assert!(delay0.as_millis() <= 125); // 100ms + 25%
-
- assert!(delay1.as_millis() >= 150); // 200ms - 25%
+
+ assert!(delay1.as_millis() >= 150); // 200ms - 25%
assert!(delay1.as_millis() <= 250); // 200ms + 25%
-
- assert!(delay2.as_millis() >= 300); // 400ms - 25%
+
+ assert!(delay2.as_millis() >= 300); // 400ms - 25%
assert!(delay2.as_millis() <= 500); // 400ms + 25%
}
-
+
#[test]
fn test_max_delay_cap() {
let policy = RetryPolicy::default();
-
+
// Very high attempt number should cap at max_delay
let delay = policy.calculate_delay(10);
assert!(delay.as_millis() <= 6250); // 5000ms + 25%
}
-
+
#[test]
fn test_should_retry() {
let policy = RetryPolicy::default();
-
+
// Retryable errors
assert!(policy.should_retry("connection timeout"));
assert!(policy.should_retry("network error"));
assert!(policy.should_retry("temporarily unavailable"));
-
+
// Non-retryable errors
assert!(!policy.should_retry("authentication failed"));
assert!(!policy.should_retry("unauthorized"));
assert!(!policy.should_retry("invalid request"));
assert!(!policy.should_retry("malformed JSON"));
}
-
+
#[tokio::test]
async fn test_circuit_breaker_state_transitions() {
let breaker = CircuitBreaker::with_config(2, Duration::from_millis(100));
-
+
// Initially closed
assert!(breaker.can_proceed().await);
-
+
// First failure - still closed (threshold is 2)
breaker.record_failure().await;
assert!(breaker.can_proceed().await);
-
+
// Second failure - should open
breaker.record_failure().await;
assert!(!breaker.can_proceed().await);
-
+
// Wait for recovery timeout
tokio::time::sleep(Duration::from_millis(150)).await;
-
+
// Should transition to half-open
assert!(breaker.can_proceed().await);
-
+
// Success in half-open closes circuit
breaker.record_success().await;
assert!(matches!(
breaker.state().await,
- CircuitState::Closed { consecutive_failures: 0 }
+ CircuitState::Closed {
+ consecutive_failures: 0
+ }
));
}
@@ -387,8 +410,7 @@ mod tests {
breaker.record_failure().await;
assert!(
breaker.can_proceed().await,
- "after {} failures (threshold 5) breaker must still be Closed",
- i
+ "after {i} failures (threshold 5) breaker must still be Closed"
);
assert!(
matches!(breaker.state().await, CircuitState::Closed { consecutive_failures: c } if c == i),
@@ -398,7 +420,10 @@ mod tests {
// 5th failure crosses threshold — should now Open.
breaker.record_failure().await;
- assert!(!breaker.can_proceed().await, "breaker must be Open at threshold");
+ assert!(
+ !breaker.can_proceed().await,
+ "breaker must be Open at threshold"
+ );
assert!(matches!(breaker.state().await, CircuitState::Open { .. }));
}
@@ -459,13 +484,17 @@ mod tests {
breaker.record_failure().await;
assert!(matches!(
breaker.state().await,
- CircuitState::Closed { consecutive_failures: 2 }
+ CircuitState::Closed {
+ consecutive_failures: 2
+ }
));
breaker.record_success().await;
assert!(matches!(
breaker.state().await,
- CircuitState::Closed { consecutive_failures: 0 }
+ CircuitState::Closed {
+ consecutive_failures: 0
+ }
));
}
@@ -476,10 +505,10 @@ mod tests {
// Record failures
assert!(!tracker.record_failure().await); // 1
assert!(!tracker.record_failure().await); // 2
- assert!(tracker.record_failure().await); // 3 - threshold reached
+ assert!(tracker.record_failure().await); // 3 - threshold reached
// Success resets counter
tracker.record_success().await;
assert!(!tracker.should_open().await);
}
-}
\ No newline at end of file
+}
diff --git a/src/mev/types.rs b/src/mev/types.rs
index 5876b7c..fd21832 100644
--- a/src/mev/types.rs
+++ b/src/mev/types.rs
@@ -1,5 +1,5 @@
//! MEV-specific types for Flashbots bundle submission
-//!
+//!
//! This module contains all the data structures used for MEV protection
//! and bundle submission to Flashbots relays.
@@ -7,15 +7,15 @@ use serde::{Deserialize, Serialize};
use std::time::Instant;
/// Represents a bundle of transactions to be submitted to Flashbots
-///
+///
/// A bundle is a collection of transactions that must be executed atomically
/// in the exact order specified. Bundles are the core primitive of Flashbots
/// and enable MEV protection and extraction strategies.
-///
+///
/// # For Developers
/// This struct is used internally by the MEV client to format transaction
/// submissions according to Flashbots specifications.
-///
+///
/// # Example
/// ```rust
/// let bundle = Bundle {
@@ -30,18 +30,18 @@ pub struct Bundle {
/// List of signed transactions in hex format (0x-prefixed)
/// Order matters - transactions execute in array order
pub txs: Vec,
-
+
/// Target block number for inclusion (hex format)
/// Bundle will only be considered for this specific block
#[serde(rename = "blockNumber")]
pub block_number: String,
-
+
/// Optional: Minimum Unix timestamp for bundle inclusion
/// Bundle won't be included before this time
/// Use case: Time-sensitive arbitrage, auction participation
#[serde(rename = "minTimestamp", skip_serializing_if = "Option::is_none")]
pub min_timestamp: Option,
-
+
/// Optional: Maximum Unix timestamp for bundle inclusion
/// Bundle won't be included after this time
/// Use case: Prevent stale trades, deadline enforcement
@@ -50,7 +50,7 @@ pub struct Bundle {
}
/// Request format for eth_sendBundle JSON-RPC method
-///
+///
/// # For Library Developers
/// This wraps the bundle with additional metadata required by the
/// Flashbots relay API specification.
@@ -58,13 +58,13 @@ pub struct Bundle {
pub struct SendBundleRequest {
/// JSON-RPC version (always "2.0")
pub jsonrpc: String,
-
+
/// Method name (always "eth_sendBundle")
pub method: String,
-
+
/// Parameters array containing the bundle
pub params: Vec,
-
+
/// Request ID for correlation
pub id: u64,
}
@@ -82,7 +82,7 @@ impl SendBundleRequest {
}
/// Response from Flashbots relay after bundle submission
-///
+///
/// # For Developers
/// Parse this response to determine if bundle was accepted and
/// monitor simulation results for debugging failed bundles.
@@ -92,14 +92,14 @@ pub struct BundleResponse {
/// Use this to track bundle status
#[serde(rename = "bundleHash")]
pub bundle_hash: String,
-
+
/// Optional simulation results if relay simulated the bundle
#[serde(skip_serializing_if = "Option::is_none")]
pub simulation: Option,
}
/// Results from bundle simulation
-///
+///
/// # For Developers
/// When a bundle fails simulation, these results help identify
/// the issue (e.g., insufficient gas, reverted transaction).
@@ -107,15 +107,15 @@ pub struct BundleResponse {
pub struct SimulationResult {
/// Whether all transactions in the bundle succeeded
pub success: bool,
-
+
/// Error message if simulation failed
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option,
-
+
/// Total gas used by all transactions
#[serde(rename = "totalGasUsed", skip_serializing_if = "Option::is_none")]
pub gas_used: Option,
-
+
/// Coinbase payment if bundle includes miner tips
#[serde(rename = "coinbaseDiff", skip_serializing_if = "Option::is_none")]
pub coinbase_diff: Option,
@@ -155,7 +155,7 @@ pub enum CircuitState {
}
/// Configuration for MEV relay client
-///
+///
/// # For Proxy Operators
/// Configure MEV protection behavior through environment variables
/// or configuration files.
@@ -163,13 +163,13 @@ pub enum CircuitState {
pub struct MevConfig {
/// Relay endpoint URL (e.g., "https://relay.flashbots.net")
pub relay_url: String,
-
+
/// Private key for signing authentication headers (hex format)
pub signing_key: String,
-
+
/// Maximum time to wait for relay response
pub request_timeout: std::time::Duration,
-
+
/// Number of blocks ahead to target for bundle inclusion
/// Default: 1 (next block)
pub blocks_ahead: u64,
@@ -199,11 +199,10 @@ mod tests {
min_timestamp: Some(1625097600),
max_timestamp: None,
};
-
+
let json = serde_json::to_string(&bundle).unwrap();
assert!(json.contains("\"blockNumber\":\"0x1234\""));
assert!(json.contains("\"minTimestamp\":1625097600"));
assert!(!json.contains("maxTimestamp")); // Should be omitted when None
}
-
-}
\ No newline at end of file
+}
diff --git a/src/proxy.rs b/src/proxy.rs
index 5c0e4d9..4eb04f6 100644
--- a/src/proxy.rs
+++ b/src/proxy.rs
@@ -2,7 +2,7 @@ use axum::{extract::State, Json};
use reqwest::Client;
use std::sync::Arc;
use std::time::{Duration, Instant};
-use tracing::{debug, error, info, warn};
+use tracing::{debug, error, warn};
use crate::{
error::{ProxyError, ProxyResult},
@@ -67,7 +67,7 @@ impl ProxyState {
let geth_client = Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
- .map_err(|e| ProxyError::InternalError(format!("HTTP client init failed: {}", e)))?;
+ .map_err(|e| ProxyError::InternalError(format!("HTTP client init failed: {e}")))?;
let write_method_limiter = Arc::new(RateLimiter::new(RateLimitConfig {
max_requests: WRITE_METHOD_DEFAULT_REQUESTS,
@@ -127,11 +127,12 @@ pub async fn handle_rpc(
Json(request): Json,
) -> ProxyResult> {
debug!("Received RPC request: method={}", request.method);
-
+
// Validate request
- request.validate()
+ request
+ .validate()
.map_err(|e| ProxyError::InvalidRequest(e.to_string()))?;
-
+
// Check if method is allowed
if !is_method_allowed(&request.method) {
state.metrics.increment_invalid_methods();
@@ -160,11 +161,12 @@ pub async fn handle_flashbots(
Json(request): Json,
) -> ProxyResult> {
debug!("Received Flashbots RPC request: method={}", request.method);
-
+
// Validate request
- request.validate()
+ request
+ .validate()
.map_err(|e| ProxyError::InvalidRequest(e.to_string()))?;
-
+
// Check if method is allowed
if !is_method_allowed(&request.method) {
state.metrics.increment_invalid_methods();
@@ -184,7 +186,10 @@ pub async fn handle_flashbots(
let response = match request.method.as_str() {
"eth_sendRawTransaction" | "eth_sendBundle" => {
- info!("Routing transaction to Flashbots");
+ // Volume-of-tx oracle if logged at INFO — operators with log
+ // shippers (journald → Loki, etc.) accidentally publish per-tx
+ // signal upstream. Keep below default verbosity.
+ debug!("Routing transaction to Flashbots");
proxy_to_flashbots(&state, request).await?
}
_ => {
@@ -221,23 +226,32 @@ pub async fn proxy_to_geth(
let response = match response_result {
Ok(resp) => resp,
Err(e) => {
+ // Log the full reqwest error locally; surface only a generic
+ // string to the caller. Tor visitors must not see Geth/network
+ // version strings.
error!("Failed to send request to Geth: {}", e);
state.geth_circuit.record_failure().await;
- return Err(ProxyError::UpstreamError(format!(
- "Geth connection failed: {}",
- e
- )));
+ return Err(ProxyError::UpstreamError(
+ "upstream unavailable".to_string(),
+ ));
}
};
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
+ // Log the upstream body locally so operators can debug. Don't echo
+ // it: Geth error bodies leak version, EIP support, and internal paths.
error!("Geth returned error status {}: {}", status, body);
- state.geth_circuit.record_failure().await;
+ // Only break the circuit on 5xx and connect failures. 4xx is usually
+ // a client-shaped error (bad params, etc.) and shouldn't trip the
+ // operator's upstream-availability signal.
+ if status.is_server_error() {
+ state.geth_circuit.record_failure().await;
+ }
return Err(ProxyError::UpstreamError(format!(
- "Geth returned status {}: {}",
- status, body
+ "upstream returned status {}",
+ status.as_u16()
)));
}
@@ -247,8 +261,7 @@ pub async fn proxy_to_geth(
error!("Failed to parse Geth response: {}", e);
state.geth_circuit.record_failure().await;
return Err(ProxyError::UpstreamError(format!(
- "Failed to parse response: {}",
- e
+ "Failed to parse response: {e}"
)));
}
};
@@ -273,7 +286,8 @@ async fn proxy_to_flashbots(
return Ok(JsonRpcResponse::error(
request.id,
-32004,
- "MEV protection not configured: set FLASHBOTS_SIGNING_KEY to enable bundle submission".to_string(),
+ "MEV protection not configured: set FLASHBOTS_SIGNING_KEY to enable bundle submission"
+ .to_string(),
None,
));
}
@@ -289,7 +303,7 @@ mod tests {
use serde_json::json;
fn create_test_state(server_url: String) -> ProxyState {
- ProxyState::new(server_url.clone(), format!("{}/flashbots", server_url))
+ ProxyState::new(server_url.clone(), format!("{server_url}/flashbots"))
.expect("ProxyState::new must succeed in tests")
}
@@ -307,12 +321,10 @@ mod tests {
// Reads are unlimited (the per-port limiter handles them, not this).
for _ in 0..50 {
- assert!(
- state
- .check_write_method_rate_limit("eth_blockNumber")
- .await
- .is_ok()
- );
+ assert!(state
+ .check_write_method_rate_limit("eth_blockNumber")
+ .await
+ .is_ok());
}
}
@@ -347,24 +359,25 @@ mod tests {
#[tokio::test]
async fn test_handle_rpc_valid_request() {
let mut server = Server::new_async().await;
- let _m = server.mock("POST", "/")
+ let _m = server
+ .mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"jsonrpc":"2.0","result":"0x123","id":1}"#)
.create();
-
+
let state = Arc::new(create_test_state(server.url()));
-
+
let request = JsonRpcRequest {
jsonrpc: "2.0".to_string(),
method: "eth_blockNumber".to_string(),
params: None,
id: Some(json!(1)),
};
-
+
let result = handle_rpc(State(state), Json(request)).await;
assert!(result.is_ok());
-
+
let response = result.unwrap().0;
assert_eq!(response.result, Some(json!("0x123")));
}
@@ -373,17 +386,17 @@ mod tests {
async fn test_handle_rpc_blocked_method() {
let server = Server::new_async().await;
let state = Arc::new(create_test_state(server.url()));
-
+
let request = JsonRpcRequest {
jsonrpc: "2.0".to_string(),
method: "eth_accounts".to_string(),
params: None,
id: Some(json!(1)),
};
-
+
let result = handle_rpc(State(state), Json(request)).await;
assert!(result.is_err());
-
+
match result.unwrap_err() {
ProxyError::MethodNotAllowed(method) => {
assert_eq!(method, "eth_accounts");
@@ -400,7 +413,8 @@ mod tests {
#[tokio::test]
async fn test_handle_flashbots_raw_tx_falls_back_to_geth() {
let mut server = Server::new_async().await;
- let _m = server.mock("POST", "/")
+ let _m = server
+ .mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"jsonrpc":"2.0","result":"0xhash","id":1}"#)
@@ -434,8 +448,13 @@ mod tests {
id: Some(json!(7)),
};
- let response = handle_flashbots(State(state), Json(request)).await.unwrap().0;
- let err = response.error.expect("expected JSON-RPC error when MEV not configured");
+ let response = handle_flashbots(State(state), Json(request))
+ .await
+ .unwrap()
+ .0;
+ let err = response
+ .error
+ .expect("expected JSON-RPC error when MEV not configured");
assert_eq!(err.code, -32004);
assert!(err.message.contains("MEV protection not configured"));
assert_eq!(response.id, Some(json!(7)));
@@ -445,17 +464,17 @@ mod tests {
async fn test_invalid_json_rpc_version() {
let server = Server::new_async().await;
let state = Arc::new(create_test_state(server.url()));
-
+
let request = JsonRpcRequest {
jsonrpc: "1.0".to_string(),
method: "eth_blockNumber".to_string(),
params: None,
id: Some(json!(1)),
};
-
+
let result = handle_rpc(State(state), Json(request)).await;
assert!(result.is_err());
-
+
match result.unwrap_err() {
ProxyError::InvalidRequest(msg) => {
assert!(msg.contains("jsonrpc version"));
@@ -463,4 +482,4 @@ mod tests {
_ => panic!("Expected InvalidRequest error"),
}
}
-}
\ No newline at end of file
+}
diff --git a/src/rate_limit.rs b/src/rate_limit.rs
index b7304f3..0a41b5e 100644
--- a/src/rate_limit.rs
+++ b/src/rate_limit.rs
@@ -45,12 +45,12 @@ impl RateLimiter {
requests: Arc::new(Mutex::new(HashMap::new())),
}
}
-
+
/// Check if a request should be allowed
pub async fn check_rate_limit(&self, identifier: &str) -> bool {
let mut requests = self.requests.lock().await;
let now = Instant::now();
-
+
match requests.get_mut(identifier) {
Some((count, window_start)) => {
// Check if we're still in the same window
@@ -70,20 +70,18 @@ impl RateLimiter {
requests.insert(identifier.to_string(), (1, now));
}
}
-
+
true
}
-
+
/// Clean up old entries (call periodically)
pub async fn cleanup(&self) {
let mut requests = self.requests.lock().await;
let now = Instant::now();
-
+
// Remove entries older than 2x the window duration
let cutoff = self.config.window_duration * 2;
- requests.retain(|_, (_, window_start)| {
- now.duration_since(*window_start) < cutoff
- });
+ requests.retain(|_, (_, window_start)| now.duration_since(*window_start) < cutoff);
}
}
@@ -127,7 +125,7 @@ pub async fn rate_limit_middleware(
#[cfg(test)]
mod tests {
use super::*;
-
+
#[tokio::test]
async fn test_rate_limiter_allows_under_limit() {
let config = RateLimitConfig {
@@ -135,16 +133,17 @@ mod tests {
window_duration: Duration::from_secs(1),
};
let limiter = RateLimiter::new(config);
-
+
// Should allow up to 5 requests
for i in 0..5 {
assert!(
limiter.check_rate_limit("test").await,
- "Request {} should be allowed", i + 1
+ "Request {} should be allowed",
+ i + 1
);
}
}
-
+
#[tokio::test]
async fn test_rate_limiter_blocks_over_limit() {
let config = RateLimitConfig {
@@ -152,16 +151,16 @@ mod tests {
window_duration: Duration::from_secs(1),
};
let limiter = RateLimiter::new(config);
-
+
// Allow first 3 requests
for _ in 0..3 {
assert!(limiter.check_rate_limit("test").await);
}
-
+
// 4th request should be blocked
assert!(!limiter.check_rate_limit("test").await);
}
-
+
#[tokio::test]
async fn test_rate_limiter_resets_after_window() {
let config = RateLimitConfig {
@@ -169,19 +168,19 @@ mod tests {
window_duration: Duration::from_millis(100),
};
let limiter = RateLimiter::new(config);
-
+
// Use up the limit
assert!(limiter.check_rate_limit("test").await);
assert!(limiter.check_rate_limit("test").await);
assert!(!limiter.check_rate_limit("test").await);
-
+
// Wait for window to expire
tokio::time::sleep(Duration::from_millis(150)).await;
-
+
// Should be allowed again
assert!(limiter.check_rate_limit("test").await);
}
-
+
#[tokio::test]
async fn test_rate_limiter_different_identifiers() {
let config = RateLimitConfig {
@@ -189,17 +188,17 @@ mod tests {
window_duration: Duration::from_secs(1),
};
let limiter = RateLimiter::new(config);
-
+
// Different identifiers should have separate limits
assert!(limiter.check_rate_limit("user1").await);
assert!(limiter.check_rate_limit("user2").await);
assert!(limiter.check_rate_limit("user3").await);
-
+
// But each is limited individually
assert!(!limiter.check_rate_limit("user1").await);
assert!(!limiter.check_rate_limit("user2").await);
}
-
+
#[tokio::test]
async fn test_cleanup_removes_old_entries() {
let config = RateLimitConfig {
@@ -207,24 +206,24 @@ mod tests {
window_duration: Duration::from_millis(50),
};
let limiter = RateLimiter::new(config);
-
+
// Add some entries
assert!(limiter.check_rate_limit("old").await);
assert!(limiter.check_rate_limit("new").await);
-
+
// Wait for entries to become old
tokio::time::sleep(Duration::from_millis(150)).await;
-
+
// Add a fresh entry
assert!(limiter.check_rate_limit("fresh").await);
-
+
// Run cleanup
limiter.cleanup().await;
-
+
// Check internal state
let requests = limiter.requests.lock().await;
assert!(!requests.contains_key("old"));
assert!(!requests.contains_key("new"));
assert!(requests.contains_key("fresh"));
}
-}
\ No newline at end of file
+}
diff --git a/src/rpc_types.rs b/src/rpc_types.rs
index 4701f88..b5d598a 100644
--- a/src/rpc_types.rs
+++ b/src/rpc_types.rs
@@ -116,7 +116,6 @@ mod tests {
assert_eq!(empty_method.validate(), Err("Method cannot be empty"));
}
-
#[test]
fn test_serialization_roundtrip() {
let request = JsonRpcRequest {
diff --git a/src/security.rs b/src/security.rs
index 1bbcf92..fc295e8 100644
--- a/src/security.rs
+++ b/src/security.rs
@@ -81,11 +81,7 @@ fn first_env_var(names: &[&str]) -> Option {
}
if let (Some(idx), true) = (found_at, names.len() > 1) {
if idx > 0 {
- tracing::warn!(
- "{} is deprecated; prefer {}",
- names[idx],
- names[0]
- );
+ tracing::warn!("{} is deprecated; prefer {}", names[idx], names[0]);
}
}
value
@@ -178,7 +174,10 @@ pub async fn add_security_headers(request: Request, next: Next) -> Response {
let mut response = next.run(request).await;
let headers = response.headers_mut();
- headers.insert("X-Content-Type-Options", HeaderValue::from_static("nosniff"));
+ headers.insert(
+ "X-Content-Type-Options",
+ HeaderValue::from_static("nosniff"),
+ );
headers.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
headers.insert("X-XSS-Protection", HeaderValue::from_static("0"));
headers.insert("Referrer-Policy", HeaderValue::from_static("no-referrer"));
@@ -188,9 +187,11 @@ pub async fn add_security_headers(request: Request, next: Next) -> Response {
);
headers.insert("Pragma", HeaderValue::from_static("no-cache"));
headers.insert("Expires", HeaderValue::from_static("0"));
+ // Strip identifying headers. Earlier revisions added an `X-Service: TorPC`
+ // banner here, defeating the point: anyone scanning .onion services could
+ // identify a TorPC node with a single HEAD request.
headers.remove("Server");
- headers.insert("X-Service", HeaderValue::from_static("TorPC"));
-
+
response
}
@@ -208,7 +209,9 @@ pub async fn add_security_headers(request: Request, next: Next) -> Response {
/// Privacy note: every field returned here must be safe to share with an
/// anonymous Tor client. The fields below are deliberately vanilla.
pub async fn health_check(
- axum::extract::State(state): axum::extract::State>,
+ axum::extract::State(state): axum::extract::State<
+ std::sync::Arc,
+ >,
) -> Result, StatusCode> {
Ok(axum::Json(json!({
"status": "ok",
@@ -228,7 +231,9 @@ pub async fn health_check(
/// MEV relay (when configured). This is where component-state observability
/// lives now that `/health` is intentionally minimal.
pub async fn security_metrics(
- axum::extract::State(state): axum::extract::State>,
+ axum::extract::State(state): axum::extract::State<
+ std::sync::Arc,
+ >,
) -> Result, StatusCode> {
let geth_circuit = state.base_state.geth_circuit.state_summary();
let (mev_relay_status, mev_circuit) = match &state.mev_client {
@@ -355,12 +360,12 @@ mod tests {
"should never get here"
}
- let app = Router::new()
- .route("/slow", get(slow_handler))
- .layer(axum::middleware::from_fn_with_state(
+ let app = Router::new().route("/slow", get(slow_handler)).layer(
+ axum::middleware::from_fn_with_state(
Duration::from_millis(50),
json_rpc_timeout_middleware,
- ));
+ ),
+ );
let server = TestServer::new(app).unwrap();
let response = server.get("/slow").await;
@@ -405,26 +410,26 @@ mod tests {
let config = SecurityConfig::from_env();
assert_eq!(config.max_body_size, 1024 * 1024);
assert_eq!(config.request_timeout.as_secs(), 30);
- assert_eq!(config.strict_headers, true);
+ assert!(config.strict_headers);
// Test with environment variables set
std::env::set_var("MAX_BODY_SIZE", "2097152"); // 2MB
std::env::set_var("REQUEST_TIMEOUT", "60");
std::env::set_var("STRICT_HEADERS", "false");
-
+
let env_config = SecurityConfig::from_env();
assert_eq!(env_config.max_body_size, 2097152);
assert_eq!(env_config.request_timeout.as_secs(), 60);
- assert_eq!(env_config.strict_headers, false);
-
+ assert!(!env_config.strict_headers);
+
// Test with invalid values (should fall back to defaults)
std::env::set_var("MAX_BODY_SIZE", "invalid");
std::env::set_var("REQUEST_TIMEOUT", "invalid");
-
+
let fallback_config = SecurityConfig::from_env();
assert_eq!(fallback_config.max_body_size, 1024 * 1024); // Default
assert_eq!(fallback_config.request_timeout.as_secs(), 30); // Default
-
+
// Clean up environment variables
std::env::remove_var("MAX_BODY_SIZE");
std::env::remove_var("REQUEST_TIMEOUT");
@@ -433,4 +438,4 @@ mod tests {
// Note: Direct testing of add_security_headers requires mocking Next
// which is complex. These are tested in integration tests instead.
-}
\ No newline at end of file
+}
diff --git a/src/tor.rs b/src/tor.rs
index 76dabf1..1082378 100644
--- a/src/tor.rs
+++ b/src/tor.rs
@@ -1,6 +1,6 @@
+use anyhow::{Context, Result};
use std::fs;
use std::path::Path;
-use anyhow::{Context, Result};
use tracing::{info, warn};
/// Tor service configuration and utilities
@@ -16,7 +16,7 @@ impl TorService {
config_path: "./configs/torrc".to_string(),
}
}
-
+
/// Check if Tor is properly configured.
///
/// In addition to verifying the torrc file and data directory, this
@@ -32,8 +32,16 @@ impl TorService {
/// administrator that did `chmod 755 data/tor/torpc/` can't accidentally
/// expose the service key to other users.
pub fn check_configuration(&self) -> Result<()> {
+ // No torrc → operator is running torpc without a hidden service
+ // (e.g. for local dev or behind their own reverse proxy). Skip the
+ // anonymity audit and the data-dir setup; it would be both useless
+ // and intrusive (creating ./data/tor/torpc out of thin air).
if !Path::new(&self.config_path).exists() {
- anyhow::bail!("Tor configuration file not found at: {}", self.config_path);
+ info!(
+ "torrc not found at {}; skipping Tor configuration check",
+ self.config_path
+ );
+ return Ok(());
}
// Refuse to start if torrc disables anonymity, unless explicitly
@@ -51,8 +59,7 @@ impl TorService {
let val = tokens.next().unwrap_or("");
let is_anonymity_disabling = matches!(
(key, val),
- ("HiddenServiceSingleHopMode", "1")
- | ("HiddenServiceNonAnonymousMode", "1")
+ ("HiddenServiceSingleHopMode", "1") | ("HiddenServiceNonAnonymousMode", "1")
);
if is_anonymity_disabling {
if allow_override {
@@ -78,8 +85,7 @@ impl TorService {
let data_dir = Path::new("./data/tor/torpc");
if !data_dir.exists() {
warn!("Tor data directory doesn't exist, creating it...");
- fs::create_dir_all(data_dir)
- .context("Failed to create Tor data directory")?;
+ fs::create_dir_all(data_dir).context("Failed to create Tor data directory")?;
}
#[cfg(unix)]
@@ -90,8 +96,8 @@ impl TorService {
.context("Failed to set Tor directory permissions to 0700")?;
// Verify the bits actually stuck — some filesystems silently
// ignore mode changes (e.g. SMB mounts).
- let metadata = fs::metadata(data_dir)
- .context("Failed to read Tor data directory metadata")?;
+ let metadata =
+ fs::metadata(data_dir).context("Failed to read Tor data directory metadata")?;
let mode = metadata.permissions().mode() & 0o777;
if mode != 0o700 {
anyhow::bail!(
@@ -106,44 +112,44 @@ impl TorService {
info!("Tor configuration verified");
Ok(())
}
-
+
/// Get the .onion hostname if available
pub fn get_hostname(&self) -> Result