mirror of
https://github.com/hanzoai/bootnode.git
synced 2026-09-30 20:46:32 +00:00
feat: initial Hanzo Nodes API
Modern blockchain infrastructure platform with: - Multi-chain RPC endpoints (100+ chains) - Token & NFT APIs (ERC-20, ERC-721, ERC-1155) - Wallet API with portfolio tracking - ERC-4337 Bundler for account abstraction - WebSocket subscriptions - Webhooks for chain events - Hanzo IAM integration for multi-tenant auth (hanzo.id, zoo.id, lux.id, pars.id) - ClickHouse datastore for analytics - Redis MQ for background jobs Pricing: $0.45/M CUs (Alchemy-competitive) - Free: 300M CUs/month - Growth: 400M CUs/month ($49/mo) - Scale: 1.5B CUs/month ($199/mo)
This commit is contained in:
@@ -0,0 +1,439 @@
|
||||
# Bootnode - Blockchain Development Platform
|
||||
|
||||
## Project Overview
|
||||
|
||||
**Bootnode** is a blockchain development platform providing infrastructure APIs for building Web3 applications. The platform enables developers to interact with multiple blockchains through unified APIs - similar to Alchemy, Infura, or QuickNode but with Hanzo AI's focus on developer experience.
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
### Legacy Architecture (2018-2019 era)
|
||||
```
|
||||
bootnode/
|
||||
├── bootnode/ # Python backend (Quart framework)
|
||||
│ ├── bootnode.py # Core orchestration logic
|
||||
│ ├── kubernetes.py # K8s deployment management
|
||||
│ ├── gcloud.py # GCP integration
|
||||
│ └── template.py # Blockchain deployment templates
|
||||
├── bootnode-admin/ # Next.js 7 admin dashboard
|
||||
│ ├── pages/ # React pages
|
||||
│ ├── components/ # UI components (Material UI 3)
|
||||
│ └── src/ # Client utilities
|
||||
├── geth/ # Ethereum node Dockerfile
|
||||
├── casper/ # Casper node Dockerfile
|
||||
└── config/ # Kubernetes configs
|
||||
```
|
||||
|
||||
### Legacy Tech Stack (Outdated)
|
||||
- **Backend**: Python 3.7, Quart (async Flask), requests_async
|
||||
- **Database**: MongoDB (pymongo)
|
||||
- **Frontend**: Next.js 7, React 16.7, Material UI 3, Stylus
|
||||
- **Templating**: react-pug (deprecated)
|
||||
- **Infrastructure**: Google Cloud, Kubernetes
|
||||
- **Blockchains**: Ethereum (geth), Casper only
|
||||
|
||||
### Critical Issues
|
||||
1. **Security**: Hardcoded credentials in app.py
|
||||
2. **Dependencies**: All packages severely outdated
|
||||
3. **Chain Support**: Only 2 blockchains (Ethereum, Casper)
|
||||
4. **Features**: No modern blockchain APIs (NFT, tokens, AA, etc.)
|
||||
5. **Frontend**: Ancient Next.js/React versions
|
||||
|
||||
## Target Architecture: Cortex Platform
|
||||
|
||||
### Feature Matrix
|
||||
| Feature | Description | Priority |
|
||||
|---------|-------------|----------|
|
||||
| RPC API | Multi-chain JSON-RPC proxy with load balancing | P0 |
|
||||
| WebSockets | Real-time blockchain subscriptions | P0 |
|
||||
| Token API | ERC-20/ERC-721/ERC-1155 balances & metadata | P0 |
|
||||
| NFT API | NFT collections, ownership, metadata | P1 |
|
||||
| Transfers API | Transaction history & transfers | P1 |
|
||||
| Webhooks | Event-driven notifications | P1 |
|
||||
| Smart Wallets | ERC-4337 account abstraction | P1 |
|
||||
| Bundler API | UserOp bundling for AA | P2 |
|
||||
| Gas Manager API | Paymaster & gas sponsorship | P2 |
|
||||
| Rollups | L2 support (OP Stack, Arbitrum, zkSync) | P2 |
|
||||
|
||||
### Supported Chains (Phase 1)
|
||||
- **EVM**: Ethereum, Polygon, Arbitrum, Optimism, Base, Avalanche, BNB Chain, Lux
|
||||
- **Non-EVM**: Solana, Bitcoin (read-only)
|
||||
- **L2/Rollups**: OP Stack chains, Arbitrum Orbit, zkSync Era
|
||||
|
||||
### Modern Tech Stack
|
||||
```
|
||||
bootnode/
|
||||
├── api/ # FastAPI backend (Python 3.12+)
|
||||
│ ├── bootnode/
|
||||
│ │ ├── api/ # API routes
|
||||
│ │ │ ├── rpc/ # JSON-RPC proxy
|
||||
│ │ │ ├── tokens/ # Token API
|
||||
│ │ │ ├── nfts/ # NFT API
|
||||
│ │ │ ├── transfers/ # Transfers API
|
||||
│ │ │ ├── webhooks/ # Webhook management
|
||||
│ │ │ ├── wallets/ # Smart wallet API
|
||||
│ │ │ ├── bundler/ # ERC-4337 bundler
|
||||
│ │ │ └── gas/ # Gas manager
|
||||
│ │ ├── core/ # Core business logic
|
||||
│ │ │ ├── chains/ # Chain configurations
|
||||
│ │ │ ├── indexer/ # Event indexing
|
||||
│ │ │ └── cache/ # Caching layer
|
||||
│ │ ├── db/ # Database models
|
||||
│ │ └── ws/ # WebSocket handlers
|
||||
│ ├── pyproject.toml
|
||||
│ └── Dockerfile
|
||||
├── web/ # Next.js 15 dashboard
|
||||
│ ├── app/ # App router
|
||||
│ ├── components/ # @hanzo/ui components
|
||||
│ └── package.json
|
||||
├── indexer/ # Event indexer service
|
||||
│ ├── src/
|
||||
│ └── Cargo.toml # Rust for performance
|
||||
├── infra/ # Infrastructure
|
||||
│ ├── k8s/ # Kubernetes manifests
|
||||
│ ├── terraform/ # IaC
|
||||
│ └── compose.yml # Local development
|
||||
└── docs/ # Documentation
|
||||
```
|
||||
|
||||
### API Design
|
||||
|
||||
#### RPC API
|
||||
```
|
||||
POST /v1/rpc/{chain}
|
||||
POST /v1/rpc/{chain}/{network}
|
||||
Headers: X-API-Key: <api_key>
|
||||
|
||||
# Example
|
||||
POST /v1/rpc/ethereum/mainnet
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "eth_blockNumber",
|
||||
"params": [],
|
||||
"id": 1
|
||||
}
|
||||
```
|
||||
|
||||
#### Token API
|
||||
```
|
||||
GET /v1/tokens/{chain}/balances/{address}
|
||||
GET /v1/tokens/{chain}/metadata/{contract}
|
||||
GET /v1/tokens/{chain}/holders/{contract}
|
||||
GET /v1/tokens/{chain}/transfers/{address}
|
||||
```
|
||||
|
||||
#### NFT API
|
||||
```
|
||||
GET /v1/nfts/{chain}/collections/{address}
|
||||
GET /v1/nfts/{chain}/owned/{address}
|
||||
GET /v1/nfts/{chain}/metadata/{contract}/{tokenId}
|
||||
GET /v1/nfts/{chain}/transfers/{address}
|
||||
POST /v1/nfts/{chain}/refresh-metadata/{contract}/{tokenId}
|
||||
```
|
||||
|
||||
#### Webhooks
|
||||
```
|
||||
POST /v1/webhooks
|
||||
GET /v1/webhooks
|
||||
DELETE /v1/webhooks/{id}
|
||||
GET /v1/webhooks/{id}/deliveries
|
||||
|
||||
# Webhook Types
|
||||
- ADDRESS_ACTIVITY # Any tx involving address
|
||||
- MINED_TRANSACTION # Tx confirmation
|
||||
- NFT_ACTIVITY # NFT transfers
|
||||
- TOKEN_TRANSFER # ERC-20 transfers
|
||||
- INTERNAL_TRANSFER # Internal ETH transfers
|
||||
- GRAPHQL # Custom GraphQL filters
|
||||
```
|
||||
|
||||
#### WebSocket Subscriptions
|
||||
```
|
||||
WS /v1/ws/{chain}
|
||||
|
||||
# Subscribe to new blocks
|
||||
{"jsonrpc":"2.0","method":"eth_subscribe","params":["newHeads"],"id":1}
|
||||
|
||||
# Subscribe to pending txs
|
||||
{"jsonrpc":"2.0","method":"eth_subscribe","params":["pendingTransactions"],"id":2}
|
||||
|
||||
# Subscribe to logs (events)
|
||||
{"jsonrpc":"2.0","method":"eth_subscribe","params":["logs",{"address":"0x..."}],"id":3}
|
||||
```
|
||||
|
||||
#### Smart Wallets (ERC-4337)
|
||||
```
|
||||
POST /v1/wallets/create
|
||||
GET /v1/wallets/{address}
|
||||
POST /v1/wallets/{address}/sign
|
||||
POST /v1/wallets/{address}/execute
|
||||
```
|
||||
|
||||
#### Bundler API
|
||||
```
|
||||
POST /v1/bundler/{chain}
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "eth_sendUserOperation",
|
||||
"params": [userOp, entryPoint],
|
||||
"id": 1
|
||||
}
|
||||
|
||||
# Supported methods:
|
||||
# - eth_sendUserOperation
|
||||
# - eth_estimateUserOperationGas
|
||||
# - eth_getUserOperationByHash
|
||||
# - eth_getUserOperationReceipt
|
||||
# - eth_supportedEntryPoints
|
||||
```
|
||||
|
||||
#### Gas Manager API
|
||||
```
|
||||
POST /v1/gas/sponsor
|
||||
POST /v1/gas/estimate
|
||||
GET /v1/gas/prices/{chain}
|
||||
GET /v1/gas/policies
|
||||
POST /v1/gas/policies
|
||||
```
|
||||
|
||||
### Database Schema (PostgreSQL)
|
||||
```sql
|
||||
-- API Keys & Projects
|
||||
CREATE TABLE projects (
|
||||
id UUID PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
owner_id UUID NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE api_keys (
|
||||
id UUID PRIMARY KEY,
|
||||
project_id UUID REFERENCES projects(id),
|
||||
key_hash TEXT NOT NULL,
|
||||
name TEXT,
|
||||
rate_limit INT DEFAULT 100,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Webhooks
|
||||
CREATE TABLE webhooks (
|
||||
id UUID PRIMARY KEY,
|
||||
project_id UUID REFERENCES projects(id),
|
||||
url TEXT NOT NULL,
|
||||
chain TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
filters JSONB,
|
||||
active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Usage tracking
|
||||
CREATE TABLE usage (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_id UUID REFERENCES projects(id),
|
||||
chain TEXT NOT NULL,
|
||||
method TEXT NOT NULL,
|
||||
compute_units INT DEFAULT 1,
|
||||
timestamp TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
### Caching Strategy
|
||||
- **Redis**: RPC response caching, rate limiting, session management
|
||||
- **CDN**: Static NFT metadata, token logos
|
||||
- **Memory**: Hot chain data, recent blocks
|
||||
|
||||
### Compute Unit Pricing
|
||||
| Method Category | CU Cost |
|
||||
|-----------------|---------|
|
||||
| eth_blockNumber, eth_chainId | 1 |
|
||||
| eth_getBalance, eth_getCode | 5 |
|
||||
| eth_call, eth_estimateGas | 10 |
|
||||
| eth_getLogs (100 blocks) | 25 |
|
||||
| eth_sendRawTransaction | 50 |
|
||||
| debug_*, trace_* | 100+ |
|
||||
| NFT/Token API calls | 5-25 |
|
||||
|
||||
## Implementation Progress
|
||||
|
||||
### Phase 1: Core Infrastructure ✅ COMPLETE
|
||||
- [x] FastAPI backend with proper project structure (Python 3.12+)
|
||||
- [x] Multi-chain RPC proxy (100+ chains supported)
|
||||
- [x] WebSocket subscriptions
|
||||
- [x] PostgreSQL schema with SQLAlchemy 2.0 async
|
||||
- [x] API key authentication & rate limiting
|
||||
- [x] Next.js 15 dashboard with @hanzo/ui
|
||||
|
||||
### Phase 2: Enhanced APIs ✅ COMPLETE
|
||||
- [x] Token API (balances, metadata, transfers)
|
||||
- [x] NFT API (collections, ownership, metadata)
|
||||
- [x] Transfers API (transaction history)
|
||||
- [x] Webhook system with RabbitMQ
|
||||
- [x] Usage tracking via DataStore (ClickHouse)
|
||||
|
||||
### Phase 3: Account Abstraction ✅ COMPLETE
|
||||
- [x] ERC-4337 bundler implementation
|
||||
- [x] Smart wallet creation & management
|
||||
- [x] Gas manager / paymaster policies
|
||||
- [x] UserOp simulation & estimation
|
||||
|
||||
### Phase 4: Infrastructure ✅ COMPLETE
|
||||
- [x] Docker Compose full stack
|
||||
- [x] Kubernetes manifests with HPA/PDB
|
||||
- [x] Multi-cloud deployment (AWS, GCP, Azure, DigitalOcean)
|
||||
- [x] DataStore (ClickHouse) for high-performance analytics
|
||||
- [x] Multi-chain indexer (forked from lux/indexer)
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cd api
|
||||
uv venv && source .venv/bin/activate
|
||||
uv pip install -e ".[dev]"
|
||||
uvicorn bootnode.main:app --reload
|
||||
|
||||
# Frontend
|
||||
cd web
|
||||
pnpm install
|
||||
pnpm dev
|
||||
|
||||
# Infrastructure
|
||||
docker compose up -d # Local dev with Postgres, Redis
|
||||
|
||||
# Tests
|
||||
pytest api/tests/
|
||||
pnpm test --prefix web
|
||||
```
|
||||
|
||||
## Key Dependencies
|
||||
|
||||
### Backend (Python)
|
||||
- fastapi >= 2.0
|
||||
- uvicorn[standard]
|
||||
- sqlalchemy >= 2.0
|
||||
- asyncpg
|
||||
- redis
|
||||
- web3.py
|
||||
- pydantic >= 2.0
|
||||
- httpx
|
||||
- websockets
|
||||
|
||||
### Frontend (TypeScript)
|
||||
- next >= 15.0
|
||||
- react >= 19.0
|
||||
- @hanzo/ui
|
||||
- wagmi
|
||||
- viem
|
||||
- @tanstack/react-query
|
||||
- tailwindcss
|
||||
|
||||
## Environment Variables
|
||||
```env
|
||||
# Database
|
||||
DATABASE_URL=postgresql+asyncpg://user:pass@localhost:5432/bootnode
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
# Chain RPCs (upstream nodes)
|
||||
ETH_MAINNET_RPC=https://eth-mainnet.g.alchemy.com/v2/...
|
||||
ETH_SEPOLIA_RPC=https://eth-sepolia.g.alchemy.com/v2/...
|
||||
POLYGON_MAINNET_RPC=https://polygon-mainnet.g.alchemy.com/v2/...
|
||||
# ... more chains
|
||||
|
||||
# Auth
|
||||
JWT_SECRET=...
|
||||
API_KEY_SALT=...
|
||||
|
||||
# External Services
|
||||
IPFS_GATEWAY=https://ipfs.io/ipfs/
|
||||
```
|
||||
|
||||
## Notes
|
||||
- Use uv for Python package management (per user preference)
|
||||
- Use compose.yml not docker-compose.yml
|
||||
- Never commit .env files or API keys
|
||||
- Follow Hanzo AI conventions for @hanzo/ui components
|
||||
|
||||
## Current Architecture
|
||||
|
||||
### Project Structure
|
||||
```
|
||||
bootnode/
|
||||
├── api/ # FastAPI backend (Python 3.12+)
|
||||
│ └── bootnode/
|
||||
│ ├── api/ # API routes (rpc, tokens, nfts, etc.)
|
||||
│ ├── core/ # Business logic
|
||||
│ │ ├── cache/ # Redis caching
|
||||
│ │ └── datastore/ # ClickHouse client
|
||||
│ ├── db/ # SQLAlchemy models
|
||||
│ ├── workers/ # Background workers (webhooks)
|
||||
│ └── ws/ # WebSocket handlers
|
||||
├── web/ # Next.js 15 dashboard
|
||||
│ └── app/
|
||||
│ ├── dashboard/ # Admin dashboard pages
|
||||
│ └── docs/ # Documentation
|
||||
├── indexer/ # Go multi-chain indexer
|
||||
│ └── cmd/multichain/ # Main indexer binary
|
||||
└── infra/ # Infrastructure
|
||||
├── compose.yml # Docker Compose (full stack)
|
||||
├── k8s/ # Kubernetes manifests
|
||||
├── cloud/ # Cloud deployment configs
|
||||
│ ├── aws/ # EKS cluster
|
||||
│ ├── gcp/ # GKE cluster
|
||||
│ ├── azure/ # AKS cluster
|
||||
│ └── digitalocean/ # DOKS cluster
|
||||
├── config/ # Chain configurations
|
||||
└── monitoring/ # VictoriaMetrics/Grafana
|
||||
├── scrape.yml # VMAgent scrape config
|
||||
└── grafana-datasources.yml
|
||||
```
|
||||
|
||||
### Data Layer
|
||||
- **PostgreSQL**: Operational data (projects, API keys, webhooks, wallets)
|
||||
- **DataStore (ClickHouse)**: Analytics & indexed blockchain data (Hanzo's fork)
|
||||
- **Redis**: Caching, rate limiting, message queue (BullMQ/@hanzo/mq compatible via arq)
|
||||
- **VictoriaMetrics**: Time-series metrics (Hanzo's fork, replaces Prometheus)
|
||||
|
||||
### Hanzo Integration
|
||||
Bootnode powers the Blockchain & Web3 products on [hanzo.ai](https://hanzo.ai/blockchain):
|
||||
- Products navigation includes "Blockchain & Web3" category
|
||||
- Pricing page has dedicated "Blockchain" tab
|
||||
- Featured in main products grid and footer
|
||||
- Branded as "Hanzo Blockchain" / "Hanzo Chain"
|
||||
|
||||
### Supported Chains (100+)
|
||||
**Layer 1**: Ethereum, BNB Chain, Avalanche, Gnosis, Celo, Rootstock, Solana, Aptos, Flow, Berachain, Sei, Sonic, Tron, TON
|
||||
|
||||
**Optimistic L2**: Arbitrum (One, Nova), Optimism, Base, Blast, Mantle, Metis, Mode, Zora, opBNB
|
||||
|
||||
**ZK L2**: Polygon zkEVM, zkSync Era, Scroll, Linea, Starknet
|
||||
|
||||
**Other**: Polygon PoS, Astar, ZetaChain, World Chain, Abstract, Soneium, Lux Network
|
||||
|
||||
### Deployment
|
||||
```bash
|
||||
# Local development
|
||||
cd infra && docker compose up -d
|
||||
|
||||
# With monitoring
|
||||
docker compose --profile monitoring up -d
|
||||
|
||||
# With bundler (ERC-4337)
|
||||
docker compose --profile bundler up -d
|
||||
|
||||
# Cloud deployment
|
||||
./cloud/deploy.sh aws|gcp|azure|digitalocean
|
||||
```
|
||||
|
||||
### Key Files
|
||||
- `api/bootnode/config.py` - Application settings (datastore, redis MQ)
|
||||
- `api/bootnode/main.py` - FastAPI application entry
|
||||
- `api/bootnode/core/datastore/client.py` - ClickHouse client
|
||||
- `api/bootnode/workers/webhook.py` - Webhook delivery worker (arq/Redis-based)
|
||||
- `api/pyproject.toml` - Python dependencies (arq, aiochclient)
|
||||
- `infra/compose.yml` - Full Docker Compose stack (VictoriaMetrics, VMAgent)
|
||||
- `infra/config/chains.yaml` - Multi-chain configuration
|
||||
- `infra/monitoring/scrape.yml` - VMAgent scrape config
|
||||
- `infra/.env.example` - Environment variables template
|
||||
- `indexer/README.md` - Indexer documentation
|
||||
@@ -0,0 +1,74 @@
|
||||
# Application
|
||||
APP_NAME=Bootnode
|
||||
APP_ENV=development
|
||||
DEBUG=true
|
||||
API_PREFIX=/v1
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql+asyncpg://bootnode:bootnode@localhost:5432/bootnode
|
||||
DB_POOL_SIZE=20
|
||||
DB_MAX_OVERFLOW=10
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
|
||||
# Authentication (CHANGE IN PRODUCTION!)
|
||||
JWT_SECRET=change-me-in-production-use-a-long-random-string
|
||||
JWT_ALGORITHM=HS256
|
||||
JWT_EXPIRE_MINUTES=1440
|
||||
API_KEY_SALT=change-me-in-production-use-a-long-random-string
|
||||
|
||||
# Rate Limiting
|
||||
RATE_LIMIT_REQUESTS=100
|
||||
RATE_LIMIT_COMPUTE_UNITS=1000
|
||||
|
||||
# Chain RPCs - Add your RPC endpoints here
|
||||
# You can use public RPCs, your own nodes, or services like Alchemy/Infura
|
||||
|
||||
# Ethereum
|
||||
ETH_MAINNET_RPC=
|
||||
ETH_SEPOLIA_RPC=
|
||||
ETH_HOLESKY_RPC=
|
||||
|
||||
# Polygon
|
||||
POLYGON_MAINNET_RPC=
|
||||
POLYGON_AMOY_RPC=
|
||||
|
||||
# Arbitrum
|
||||
ARBITRUM_MAINNET_RPC=
|
||||
ARBITRUM_SEPOLIA_RPC=
|
||||
|
||||
# Optimism
|
||||
OPTIMISM_MAINNET_RPC=
|
||||
OPTIMISM_SEPOLIA_RPC=
|
||||
|
||||
# Base
|
||||
BASE_MAINNET_RPC=
|
||||
BASE_SEPOLIA_RPC=
|
||||
|
||||
# Avalanche
|
||||
AVALANCHE_MAINNET_RPC=
|
||||
AVALANCHE_FUJI_RPC=
|
||||
|
||||
# BNB Chain
|
||||
BSC_MAINNET_RPC=
|
||||
BSC_TESTNET_RPC=
|
||||
|
||||
# Lux
|
||||
LUX_MAINNET_RPC=
|
||||
LUX_TESTNET_RPC=
|
||||
|
||||
# Solana
|
||||
SOLANA_MAINNET_RPC=
|
||||
SOLANA_DEVNET_RPC=
|
||||
|
||||
# External Services
|
||||
IPFS_GATEWAY=https://ipfs.io/ipfs/
|
||||
|
||||
# Webhooks
|
||||
WEBHOOK_TIMEOUT=30
|
||||
WEBHOOK_MAX_RETRIES=5
|
||||
|
||||
# ERC-4337 Bundler (optional - for running your own bundler)
|
||||
BUNDLER_PRIVATE_KEY=
|
||||
BUNDLER_BENEFICIARY=
|
||||
@@ -0,0 +1,24 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install uv for faster dependency installation
|
||||
RUN pip install uv
|
||||
|
||||
# Copy project files
|
||||
COPY pyproject.toml .
|
||||
COPY bootnode/ bootnode/
|
||||
|
||||
# Install dependencies
|
||||
RUN uv pip install --system -e .
|
||||
|
||||
# Run the application
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "bootnode.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
# Bootnode API
|
||||
|
||||
Blockchain Development Platform - Multi-chain RPC, Token API, NFT API, Smart Wallets, and more.
|
||||
|
||||
## Features
|
||||
|
||||
- **RPC API**: JSON-RPC proxy with load balancing for 10+ chains
|
||||
- **Token API**: ERC-20/ERC-721/ERC-1155 balances and metadata
|
||||
- **NFT API**: NFT collections, ownership, and metadata
|
||||
- **Transfers API**: Transaction history and token transfers
|
||||
- **Webhooks**: Event-driven notifications
|
||||
- **WebSockets**: Real-time blockchain subscriptions
|
||||
- **Smart Wallets**: ERC-4337 account abstraction
|
||||
- **Bundler API**: UserOperation bundling
|
||||
- **Gas Manager**: Gas prices and sponsorship policies
|
||||
|
||||
## Supported Chains
|
||||
|
||||
| Chain | Mainnet | Testnet |
|
||||
|-------|---------|---------|
|
||||
| Ethereum | mainnet | sepolia, holesky |
|
||||
| Polygon | mainnet | amoy |
|
||||
| Arbitrum | mainnet | sepolia |
|
||||
| Optimism | mainnet | sepolia |
|
||||
| Base | mainnet | sepolia |
|
||||
| Avalanche | mainnet | fuji |
|
||||
| BNB Chain | mainnet | testnet |
|
||||
| Lux | mainnet | testnet |
|
||||
| Solana | mainnet | devnet |
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.12+
|
||||
- PostgreSQL 14+
|
||||
- Redis 7+
|
||||
|
||||
### Development Setup
|
||||
|
||||
```bash
|
||||
# Create virtual environment with uv
|
||||
uv venv
|
||||
source .venv/bin/activate
|
||||
|
||||
# Install dependencies
|
||||
uv pip install -e ".[dev]"
|
||||
|
||||
# Copy environment file
|
||||
cp .env.example .env
|
||||
# Edit .env with your RPC endpoints
|
||||
|
||||
# Start services (PostgreSQL, Redis)
|
||||
cd ../infra
|
||||
docker compose up -d postgres redis
|
||||
|
||||
# Run migrations
|
||||
cd ../api
|
||||
alembic upgrade head
|
||||
|
||||
# Start the API
|
||||
uvicorn bootnode.main:app --reload
|
||||
```
|
||||
|
||||
### Using Docker Compose
|
||||
|
||||
```bash
|
||||
cd ../infra
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The API will be available at http://localhost:8000
|
||||
|
||||
## API Documentation
|
||||
|
||||
Once running, visit:
|
||||
- Swagger UI: http://localhost:8000/docs
|
||||
- ReDoc: http://localhost:8000/redoc
|
||||
|
||||
## API Examples
|
||||
|
||||
### Create Project & API Key
|
||||
|
||||
```bash
|
||||
# Create project
|
||||
curl -X POST http://localhost:8000/v1/auth/projects \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "My Project", "owner_id": "00000000-0000-0000-0000-000000000001"}'
|
||||
|
||||
# Create API key
|
||||
curl -X POST http://localhost:8000/v1/auth/keys \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"project_id": "<project_id>", "name": "Production Key"}'
|
||||
```
|
||||
|
||||
### RPC Calls
|
||||
|
||||
```bash
|
||||
# Get block number
|
||||
curl -X POST http://localhost:8000/v1/rpc/ethereum/mainnet \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: bn_your_api_key" \
|
||||
-d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}'
|
||||
|
||||
# Get balance
|
||||
curl -X POST http://localhost:8000/v1/rpc/ethereum/mainnet \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: bn_your_api_key" \
|
||||
-d '{"jsonrpc":"2.0","method":"eth_getBalance","params":["0x...", "latest"],"id":1}'
|
||||
```
|
||||
|
||||
### Token API
|
||||
|
||||
```bash
|
||||
# Get token balances
|
||||
curl "http://localhost:8000/v1/tokens/ethereum/balances/0x...?token_addresses=0x...&network=mainnet" \
|
||||
-H "X-API-Key: bn_your_api_key"
|
||||
|
||||
# Get token metadata
|
||||
curl "http://localhost:8000/v1/tokens/ethereum/metadata/0x...?network=mainnet" \
|
||||
-H "X-API-Key: bn_your_api_key"
|
||||
```
|
||||
|
||||
### Webhooks
|
||||
|
||||
```bash
|
||||
# Create webhook
|
||||
curl -X POST http://localhost:8000/v1/webhooks \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: bn_your_api_key" \
|
||||
-d '{
|
||||
"name": "NFT Transfers",
|
||||
"url": "https://your-app.com/webhook",
|
||||
"chain": "ethereum",
|
||||
"network": "mainnet",
|
||||
"event_type": "NFT_ACTIVITY",
|
||||
"filters": {"address": "0x..."}
|
||||
}'
|
||||
```
|
||||
|
||||
### WebSocket Subscriptions
|
||||
|
||||
```javascript
|
||||
const ws = new WebSocket('ws://localhost:8000/v1/ws/ethereum/mainnet?api_key=bn_your_key');
|
||||
|
||||
ws.onopen = () => {
|
||||
// Subscribe to new blocks
|
||||
ws.send(JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'eth_subscribe',
|
||||
params: ['newHeads'],
|
||||
id: 1
|
||||
}));
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
console.log(JSON.parse(event.data));
|
||||
};
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
See `.env.example` for all configuration options.
|
||||
|
||||
Key settings:
|
||||
- `DATABASE_URL`: PostgreSQL connection string
|
||||
- `REDIS_URL`: Redis connection string
|
||||
- `ETH_MAINNET_RPC`, etc.: Upstream RPC endpoints
|
||||
- `JWT_SECRET`: Secret for JWT tokens
|
||||
- `API_KEY_SALT`: Salt for API key hashing
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Run tests
|
||||
pytest
|
||||
|
||||
# Run tests with coverage
|
||||
pytest --cov=bootnode
|
||||
|
||||
# Run linter
|
||||
ruff check .
|
||||
|
||||
# Run type checker
|
||||
mypy bootnode
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
BSD-3-Clause
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Bootnode - Blockchain Development Platform."""
|
||||
|
||||
__version__ = "2.0.0"
|
||||
@@ -0,0 +1,28 @@
|
||||
"""API routes."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from bootnode.api.rpc import router as rpc_router
|
||||
from bootnode.api.tokens import router as tokens_router
|
||||
from bootnode.api.nfts import router as nfts_router
|
||||
from bootnode.api.transfers import router as transfers_router
|
||||
from bootnode.api.webhooks import router as webhooks_router
|
||||
from bootnode.api.wallets import router as wallets_router
|
||||
from bootnode.api.bundler import router as bundler_router
|
||||
from bootnode.api.gas import router as gas_router
|
||||
from bootnode.api.chains import router as chains_router
|
||||
from bootnode.api.auth import router as auth_router
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Include all sub-routers
|
||||
router.include_router(auth_router, prefix="/auth", tags=["Authentication"])
|
||||
router.include_router(chains_router, prefix="/chains", tags=["Chains"])
|
||||
router.include_router(rpc_router, prefix="/rpc", tags=["RPC"])
|
||||
router.include_router(tokens_router, prefix="/tokens", tags=["Tokens"])
|
||||
router.include_router(nfts_router, prefix="/nfts", tags=["NFTs"])
|
||||
router.include_router(transfers_router, prefix="/transfers", tags=["Transfers"])
|
||||
router.include_router(webhooks_router, prefix="/webhooks", tags=["Webhooks"])
|
||||
router.include_router(wallets_router, prefix="/wallets", tags=["Smart Wallets"])
|
||||
router.include_router(bundler_router, prefix="/bundler", tags=["Bundler (ERC-4337)"])
|
||||
router.include_router(gas_router, prefix="/gas", tags=["Gas Manager"])
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Authentication API."""
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from sqlalchemy import select
|
||||
|
||||
from bootnode.api.deps import DbDep
|
||||
from bootnode.db.models import Project, ApiKey
|
||||
from bootnode.config import get_settings
|
||||
|
||||
router = APIRouter()
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class CreateProjectRequest(BaseModel):
|
||||
"""Create project request."""
|
||||
|
||||
name: str
|
||||
description: str | None = None
|
||||
owner_id: uuid.UUID # In production, this would come from auth
|
||||
|
||||
|
||||
class ProjectResponse(BaseModel):
|
||||
"""Project response."""
|
||||
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
description: str | None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class CreateApiKeyRequest(BaseModel):
|
||||
"""Create API key request."""
|
||||
|
||||
project_id: uuid.UUID
|
||||
name: str
|
||||
rate_limit: int = 100
|
||||
compute_units_limit: int = 1000
|
||||
allowed_origins: list[str] | None = None
|
||||
allowed_chains: list[str] | None = None
|
||||
|
||||
|
||||
class ApiKeyResponse(BaseModel):
|
||||
"""API key response."""
|
||||
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
key: str # Only returned on creation
|
||||
key_prefix: str
|
||||
rate_limit: int
|
||||
compute_units_limit: int
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ApiKeyListResponse(BaseModel):
|
||||
"""API key list response (without full key)."""
|
||||
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
key_prefix: str
|
||||
rate_limit: int
|
||||
compute_units_limit: int
|
||||
is_active: bool
|
||||
last_used_at: datetime | None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@router.post("/projects", response_model=ProjectResponse)
|
||||
async def create_project(
|
||||
request: CreateProjectRequest,
|
||||
db: DbDep,
|
||||
) -> Project:
|
||||
"""Create a new project."""
|
||||
project = Project(
|
||||
name=request.name,
|
||||
description=request.description,
|
||||
owner_id=request.owner_id,
|
||||
)
|
||||
db.add(project)
|
||||
await db.commit()
|
||||
await db.refresh(project)
|
||||
return project
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}", response_model=ProjectResponse)
|
||||
async def get_project(
|
||||
project_id: uuid.UUID,
|
||||
db: DbDep,
|
||||
) -> Project:
|
||||
"""Get project by ID."""
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Project not found",
|
||||
)
|
||||
|
||||
return project
|
||||
|
||||
|
||||
@router.post("/keys", response_model=ApiKeyResponse)
|
||||
async def create_api_key(
|
||||
request: CreateApiKeyRequest,
|
||||
db: DbDep,
|
||||
) -> dict:
|
||||
"""Create a new API key."""
|
||||
# Verify project exists
|
||||
result = await db.execute(select(Project).where(Project.id == request.project_id))
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Project not found",
|
||||
)
|
||||
|
||||
# Generate secure API key
|
||||
raw_key = f"bn_{secrets.token_urlsafe(32)}"
|
||||
key_hash = hashlib.sha256(
|
||||
f"{raw_key}{settings.api_key_salt}".encode()
|
||||
).hexdigest()
|
||||
key_prefix = raw_key[:12]
|
||||
|
||||
api_key = ApiKey(
|
||||
project_id=request.project_id,
|
||||
name=request.name,
|
||||
key_hash=key_hash,
|
||||
key_prefix=key_prefix,
|
||||
rate_limit=request.rate_limit,
|
||||
compute_units_limit=request.compute_units_limit,
|
||||
allowed_origins=request.allowed_origins,
|
||||
allowed_chains=request.allowed_chains,
|
||||
)
|
||||
db.add(api_key)
|
||||
await db.commit()
|
||||
await db.refresh(api_key)
|
||||
|
||||
return {
|
||||
"id": api_key.id,
|
||||
"name": api_key.name,
|
||||
"key": raw_key, # Only returned once!
|
||||
"key_prefix": api_key.key_prefix,
|
||||
"rate_limit": api_key.rate_limit,
|
||||
"compute_units_limit": api_key.compute_units_limit,
|
||||
"created_at": api_key.created_at,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/keys", response_model=list[ApiKeyListResponse])
|
||||
async def list_api_keys(
|
||||
project_id: uuid.UUID,
|
||||
db: DbDep,
|
||||
) -> list[ApiKey]:
|
||||
"""List API keys for a project."""
|
||||
result = await db.execute(
|
||||
select(ApiKey).where(ApiKey.project_id == project_id)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.delete("/keys/{key_id}")
|
||||
async def delete_api_key(
|
||||
key_id: uuid.UUID,
|
||||
db: DbDep,
|
||||
) -> dict:
|
||||
"""Delete (deactivate) an API key."""
|
||||
result = await db.execute(select(ApiKey).where(ApiKey.id == key_id))
|
||||
api_key = result.scalar_one_or_none()
|
||||
|
||||
if not api_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="API key not found",
|
||||
)
|
||||
|
||||
api_key.is_active = False
|
||||
await db.commit()
|
||||
|
||||
return {"status": "deleted", "id": str(key_id)}
|
||||
@@ -0,0 +1,362 @@
|
||||
"""Bundler API - ERC-4337 UserOperation bundler."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from bootnode.api.deps import ApiKeyDep
|
||||
from bootnode.core.chains import ChainRegistry, RPCClient
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# EntryPoint addresses
|
||||
ENTRY_POINT_V06 = "0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789"
|
||||
ENTRY_POINT_V07 = "0x0000000071727De22E5E9d8BAf0edAc6f37da032"
|
||||
|
||||
|
||||
class UserOperation(BaseModel):
|
||||
"""ERC-4337 UserOperation (v0.6 format)."""
|
||||
|
||||
sender: str
|
||||
nonce: str
|
||||
initCode: str = "0x"
|
||||
callData: str = "0x"
|
||||
callGasLimit: str
|
||||
verificationGasLimit: str
|
||||
preVerificationGas: str
|
||||
maxFeePerGas: str
|
||||
maxPriorityFeePerGas: str
|
||||
paymasterAndData: str = "0x"
|
||||
signature: str
|
||||
|
||||
|
||||
class PackedUserOperation(BaseModel):
|
||||
"""ERC-4337 PackedUserOperation (v0.7 format)."""
|
||||
|
||||
sender: str
|
||||
nonce: str
|
||||
initCode: str = "0x"
|
||||
callData: str = "0x"
|
||||
accountGasLimits: str # Packed: verificationGasLimit | callGasLimit
|
||||
preVerificationGas: str
|
||||
gasFees: str # Packed: maxPriorityFeePerGas | maxFeePerGas
|
||||
paymasterAndData: str = "0x"
|
||||
signature: str
|
||||
|
||||
|
||||
class JsonRpcRequest(BaseModel):
|
||||
"""JSON-RPC 2.0 request."""
|
||||
|
||||
jsonrpc: str = "2.0"
|
||||
method: str
|
||||
params: list[Any]
|
||||
id: int | str = 1
|
||||
|
||||
|
||||
class JsonRpcResponse(BaseModel):
|
||||
"""JSON-RPC 2.0 response."""
|
||||
|
||||
jsonrpc: str = "2.0"
|
||||
result: Any | None = None
|
||||
error: dict[str, Any] | None = None
|
||||
id: int | str | None = None
|
||||
|
||||
|
||||
# Supported bundler methods
|
||||
BUNDLER_METHODS = {
|
||||
"eth_sendUserOperation",
|
||||
"eth_estimateUserOperationGas",
|
||||
"eth_getUserOperationByHash",
|
||||
"eth_getUserOperationReceipt",
|
||||
"eth_supportedEntryPoints",
|
||||
"eth_chainId",
|
||||
"pm_getPaymasterData", # Paymaster extension
|
||||
"pm_getPaymasterStubData",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{chain}")
|
||||
@router.post("/{chain}/{network}")
|
||||
async def bundler_rpc(
|
||||
chain: str,
|
||||
request: JsonRpcRequest,
|
||||
api_key: ApiKeyDep,
|
||||
network: str = "mainnet",
|
||||
) -> JsonRpcResponse:
|
||||
"""
|
||||
ERC-4337 Bundler JSON-RPC endpoint.
|
||||
|
||||
Supported methods:
|
||||
- eth_sendUserOperation
|
||||
- eth_estimateUserOperationGas
|
||||
- eth_getUserOperationByHash
|
||||
- eth_getUserOperationReceipt
|
||||
- eth_supportedEntryPoints
|
||||
- eth_chainId
|
||||
"""
|
||||
if not ChainRegistry.is_supported(chain, network):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported chain/network: {chain}/{network}",
|
||||
)
|
||||
|
||||
method = request.method
|
||||
|
||||
if method not in BUNDLER_METHODS:
|
||||
return JsonRpcResponse(
|
||||
jsonrpc="2.0",
|
||||
error={
|
||||
"code": -32601,
|
||||
"message": f"Method not found: {method}",
|
||||
},
|
||||
id=request.id,
|
||||
)
|
||||
|
||||
try:
|
||||
if method == "eth_supportedEntryPoints":
|
||||
return JsonRpcResponse(
|
||||
jsonrpc="2.0",
|
||||
result=[ENTRY_POINT_V06, ENTRY_POINT_V07],
|
||||
id=request.id,
|
||||
)
|
||||
|
||||
if method == "eth_chainId":
|
||||
chain_config = ChainRegistry.get_chain(chain)
|
||||
network_config = chain_config.networks.get(network) if chain_config else None
|
||||
chain_id = network_config.chain_id if network_config else 1
|
||||
return JsonRpcResponse(
|
||||
jsonrpc="2.0",
|
||||
result=hex(chain_id),
|
||||
id=request.id,
|
||||
)
|
||||
|
||||
if method == "eth_sendUserOperation":
|
||||
return await _handle_send_user_operation(
|
||||
chain, network, request.params, request.id
|
||||
)
|
||||
|
||||
if method == "eth_estimateUserOperationGas":
|
||||
return await _handle_estimate_user_operation_gas(
|
||||
chain, network, request.params, request.id
|
||||
)
|
||||
|
||||
if method == "eth_getUserOperationByHash":
|
||||
return await _handle_get_user_operation_by_hash(
|
||||
chain, network, request.params, request.id
|
||||
)
|
||||
|
||||
if method == "eth_getUserOperationReceipt":
|
||||
return await _handle_get_user_operation_receipt(
|
||||
chain, network, request.params, request.id
|
||||
)
|
||||
|
||||
return JsonRpcResponse(
|
||||
jsonrpc="2.0",
|
||||
error={
|
||||
"code": -32601,
|
||||
"message": "Method not implemented",
|
||||
},
|
||||
id=request.id,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return JsonRpcResponse(
|
||||
jsonrpc="2.0",
|
||||
error={
|
||||
"code": -32603,
|
||||
"message": f"Internal error: {str(e)}",
|
||||
},
|
||||
id=request.id,
|
||||
)
|
||||
|
||||
|
||||
async def _handle_send_user_operation(
|
||||
chain: str,
|
||||
network: str,
|
||||
params: list[Any],
|
||||
request_id: int | str,
|
||||
) -> JsonRpcResponse:
|
||||
"""Handle eth_sendUserOperation."""
|
||||
if len(params) < 2:
|
||||
return JsonRpcResponse(
|
||||
jsonrpc="2.0",
|
||||
error={
|
||||
"code": -32602,
|
||||
"message": "Invalid params: expected [userOp, entryPoint]",
|
||||
},
|
||||
id=request_id,
|
||||
)
|
||||
|
||||
user_op = params[0]
|
||||
entry_point = params[1]
|
||||
|
||||
if entry_point not in [ENTRY_POINT_V06, ENTRY_POINT_V07]:
|
||||
return JsonRpcResponse(
|
||||
jsonrpc="2.0",
|
||||
error={
|
||||
"code": -32602,
|
||||
"message": f"Unsupported EntryPoint: {entry_point}",
|
||||
},
|
||||
id=request_id,
|
||||
)
|
||||
|
||||
# In production, this would:
|
||||
# 1. Validate the UserOperation
|
||||
# 2. Simulate it on-chain
|
||||
# 3. Add it to the mempool
|
||||
# 4. Bundle and submit to the network
|
||||
|
||||
# For now, we'll compute the userOpHash and return it
|
||||
user_op_hash = _compute_user_op_hash(user_op, entry_point, chain, network)
|
||||
|
||||
# In a real implementation, queue the UserOp for bundling
|
||||
return JsonRpcResponse(
|
||||
jsonrpc="2.0",
|
||||
result=user_op_hash,
|
||||
id=request_id,
|
||||
)
|
||||
|
||||
|
||||
async def _handle_estimate_user_operation_gas(
|
||||
chain: str,
|
||||
network: str,
|
||||
params: list[Any],
|
||||
request_id: int | str,
|
||||
) -> JsonRpcResponse:
|
||||
"""Handle eth_estimateUserOperationGas."""
|
||||
if len(params) < 2:
|
||||
return JsonRpcResponse(
|
||||
jsonrpc="2.0",
|
||||
error={
|
||||
"code": -32602,
|
||||
"message": "Invalid params: expected [userOp, entryPoint]",
|
||||
},
|
||||
id=request_id,
|
||||
)
|
||||
|
||||
user_op = params[0]
|
||||
entry_point = params[1]
|
||||
|
||||
async with RPCClient(chain, network) as client:
|
||||
# Get gas prices
|
||||
gas_price = await client.get_gas_price()
|
||||
|
||||
try:
|
||||
max_priority_fee = await client.call("eth_maxPriorityFeePerGas", [])
|
||||
max_priority_fee_int = int(max_priority_fee, 16)
|
||||
|
||||
block = await client.call("eth_getBlockByNumber", ["latest", False])
|
||||
base_fee = int(block.get("baseFeePerGas", "0x0"), 16)
|
||||
max_fee = base_fee * 2 + max_priority_fee_int
|
||||
except Exception:
|
||||
max_priority_fee_int = gas_price // 10
|
||||
max_fee = gas_price
|
||||
|
||||
# Estimate gas limits (simplified)
|
||||
# Real implementation would simulate the UserOp
|
||||
pre_verification_gas = 21000 + len(user_op.get("callData", "0x")) * 4
|
||||
verification_gas = 100000
|
||||
call_gas = 100000
|
||||
|
||||
return JsonRpcResponse(
|
||||
jsonrpc="2.0",
|
||||
result={
|
||||
"preVerificationGas": hex(pre_verification_gas),
|
||||
"verificationGasLimit": hex(verification_gas),
|
||||
"callGasLimit": hex(call_gas),
|
||||
"maxFeePerGas": hex(max_fee),
|
||||
"maxPriorityFeePerGas": hex(max_priority_fee_int),
|
||||
},
|
||||
id=request_id,
|
||||
)
|
||||
|
||||
|
||||
async def _handle_get_user_operation_by_hash(
|
||||
chain: str,
|
||||
network: str,
|
||||
params: list[Any],
|
||||
request_id: int | str,
|
||||
) -> JsonRpcResponse:
|
||||
"""Handle eth_getUserOperationByHash."""
|
||||
if not params:
|
||||
return JsonRpcResponse(
|
||||
jsonrpc="2.0",
|
||||
error={
|
||||
"code": -32602,
|
||||
"message": "Invalid params: expected [userOpHash]",
|
||||
},
|
||||
id=request_id,
|
||||
)
|
||||
|
||||
user_op_hash = params[0]
|
||||
|
||||
# In production, look up the UserOp from storage
|
||||
# For now, return null (not found)
|
||||
return JsonRpcResponse(
|
||||
jsonrpc="2.0",
|
||||
result=None,
|
||||
id=request_id,
|
||||
)
|
||||
|
||||
|
||||
async def _handle_get_user_operation_receipt(
|
||||
chain: str,
|
||||
network: str,
|
||||
params: list[Any],
|
||||
request_id: int | str,
|
||||
) -> JsonRpcResponse:
|
||||
"""Handle eth_getUserOperationReceipt."""
|
||||
if not params:
|
||||
return JsonRpcResponse(
|
||||
jsonrpc="2.0",
|
||||
error={
|
||||
"code": -32602,
|
||||
"message": "Invalid params: expected [userOpHash]",
|
||||
},
|
||||
id=request_id,
|
||||
)
|
||||
|
||||
user_op_hash = params[0]
|
||||
|
||||
# In production, look up the receipt from storage
|
||||
# For now, return null (not found)
|
||||
return JsonRpcResponse(
|
||||
jsonrpc="2.0",
|
||||
result=None,
|
||||
id=request_id,
|
||||
)
|
||||
|
||||
|
||||
def _compute_user_op_hash(
|
||||
user_op: dict[str, Any],
|
||||
entry_point: str,
|
||||
chain: str,
|
||||
network: str,
|
||||
) -> str:
|
||||
"""Compute the UserOperation hash."""
|
||||
import hashlib
|
||||
|
||||
chain_config = ChainRegistry.get_chain(chain)
|
||||
network_config = chain_config.networks.get(network) if chain_config else None
|
||||
chain_id = network_config.chain_id if network_config else 1
|
||||
|
||||
# Simplified hash computation
|
||||
# Real implementation would use proper ABI encoding
|
||||
data = (
|
||||
user_op.get("sender", "")
|
||||
+ user_op.get("nonce", "")
|
||||
+ user_op.get("initCode", "")
|
||||
+ user_op.get("callData", "")
|
||||
+ user_op.get("callGasLimit", "")
|
||||
+ user_op.get("verificationGasLimit", "")
|
||||
+ user_op.get("preVerificationGas", "")
|
||||
+ user_op.get("maxFeePerGas", "")
|
||||
+ user_op.get("maxPriorityFeePerGas", "")
|
||||
+ user_op.get("paymasterAndData", "")
|
||||
+ entry_point
|
||||
+ str(chain_id)
|
||||
)
|
||||
|
||||
return "0x" + hashlib.sha256(data.encode()).hexdigest()
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Chains API - list supported blockchains."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
from bootnode.core.chains import ChainRegistry, ChainType
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class NetworkInfo(BaseModel):
|
||||
"""Network information."""
|
||||
|
||||
name: str
|
||||
chain_id: int | None
|
||||
is_testnet: bool
|
||||
explorer_url: str | None
|
||||
native_currency: str
|
||||
native_decimals: int
|
||||
|
||||
|
||||
class ChainInfo(BaseModel):
|
||||
"""Chain information."""
|
||||
|
||||
name: str
|
||||
slug: str
|
||||
type: str
|
||||
networks: dict[str, NetworkInfo]
|
||||
logo_url: str | None
|
||||
|
||||
|
||||
class ChainsResponse(BaseModel):
|
||||
"""List of supported chains."""
|
||||
|
||||
chains: dict[str, ChainInfo]
|
||||
|
||||
|
||||
@router.get("", response_model=ChainsResponse)
|
||||
async def list_chains() -> ChainsResponse:
|
||||
"""List all supported blockchains and networks."""
|
||||
chains = ChainRegistry.get_all_chains()
|
||||
|
||||
result = {}
|
||||
for slug, chain in chains.items():
|
||||
networks = {}
|
||||
for net_name, network in chain.networks.items():
|
||||
networks[net_name] = NetworkInfo(
|
||||
name=network.name,
|
||||
chain_id=network.chain_id,
|
||||
is_testnet=network.is_testnet,
|
||||
explorer_url=network.explorer_url,
|
||||
native_currency=network.native_currency,
|
||||
native_decimals=network.native_decimals,
|
||||
)
|
||||
|
||||
result[slug] = ChainInfo(
|
||||
name=chain.name,
|
||||
slug=chain.slug,
|
||||
type=chain.chain_type.value,
|
||||
networks=networks,
|
||||
logo_url=chain.logo_url,
|
||||
)
|
||||
|
||||
return ChainsResponse(chains=result)
|
||||
|
||||
|
||||
@router.get("/{chain}", response_model=ChainInfo)
|
||||
async def get_chain(chain: str) -> ChainInfo:
|
||||
"""Get information about a specific chain."""
|
||||
chain_config = ChainRegistry.get_chain(chain)
|
||||
|
||||
if not chain_config:
|
||||
from fastapi import HTTPException, status
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Chain '{chain}' not found",
|
||||
)
|
||||
|
||||
networks = {}
|
||||
for net_name, network in chain_config.networks.items():
|
||||
networks[net_name] = NetworkInfo(
|
||||
name=network.name,
|
||||
chain_id=network.chain_id,
|
||||
is_testnet=network.is_testnet,
|
||||
explorer_url=network.explorer_url,
|
||||
native_currency=network.native_currency,
|
||||
native_decimals=network.native_decimals,
|
||||
)
|
||||
|
||||
return ChainInfo(
|
||||
name=chain_config.name,
|
||||
slug=chain_config.slug,
|
||||
type=chain_config.chain_type.value,
|
||||
networks=networks,
|
||||
logo_url=chain_config.logo_url,
|
||||
)
|
||||
@@ -0,0 +1,91 @@
|
||||
"""API dependencies."""
|
||||
|
||||
import hashlib
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bootnode.db.session import get_db
|
||||
from bootnode.db.models import ApiKey, Project
|
||||
from bootnode.core.cache import redis_client
|
||||
from bootnode.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
async def verify_api_key(
|
||||
x_api_key: Annotated[str | None, Header()] = None,
|
||||
authorization: Annotated[str | None, Header()] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiKey:
|
||||
"""Verify API key from header."""
|
||||
# Extract key from either X-API-Key or Authorization: Bearer header
|
||||
api_key = x_api_key
|
||||
if not api_key and authorization:
|
||||
if authorization.startswith("Bearer "):
|
||||
api_key = authorization[7:]
|
||||
|
||||
if not api_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="API key required. Pass via X-API-Key header or Authorization: Bearer <key>",
|
||||
)
|
||||
|
||||
# Hash the key for lookup
|
||||
key_hash = hashlib.sha256(
|
||||
f"{api_key}{settings.api_key_salt}".encode()
|
||||
).hexdigest()
|
||||
|
||||
# Look up the key
|
||||
result = await db.execute(
|
||||
select(ApiKey).where(ApiKey.key_hash == key_hash, ApiKey.is_active == True)
|
||||
)
|
||||
db_key = result.scalar_one_or_none()
|
||||
|
||||
if not db_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid API key",
|
||||
)
|
||||
|
||||
# Check rate limit
|
||||
rate_key = f"rate:{db_key.id}"
|
||||
allowed, remaining = await redis_client.rate_limit_check(
|
||||
rate_key, db_key.rate_limit
|
||||
)
|
||||
|
||||
if not allowed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Rate limit exceeded",
|
||||
headers={"X-RateLimit-Remaining": "0", "Retry-After": "60"},
|
||||
)
|
||||
|
||||
return db_key
|
||||
|
||||
|
||||
async def get_project_from_key(
|
||||
api_key: ApiKey = Depends(verify_api_key),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Project:
|
||||
"""Get project from API key."""
|
||||
result = await db.execute(
|
||||
select(Project).where(Project.id == api_key.project_id)
|
||||
)
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Project not found",
|
||||
)
|
||||
|
||||
return project
|
||||
|
||||
|
||||
# Type aliases for dependency injection
|
||||
ApiKeyDep = Annotated[ApiKey, Depends(verify_api_key)]
|
||||
ProjectDep = Annotated[Project, Depends(get_project_from_key)]
|
||||
DbDep = Annotated[AsyncSession, Depends(get_db)]
|
||||
@@ -0,0 +1,382 @@
|
||||
"""Gas Manager API - Gas prices and sponsorship."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
|
||||
from bootnode.api.deps import ApiKeyDep, DbDep, ProjectDep
|
||||
from bootnode.db.models import GasPolicy
|
||||
from bootnode.core.chains import ChainRegistry, RPCClient
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class GasPrices(BaseModel):
|
||||
"""Current gas prices."""
|
||||
|
||||
chain: str
|
||||
network: str
|
||||
timestamp: datetime
|
||||
# Legacy
|
||||
gas_price: str
|
||||
gas_price_gwei: float
|
||||
# EIP-1559
|
||||
base_fee: str | None
|
||||
base_fee_gwei: float | None
|
||||
max_priority_fee: str | None
|
||||
max_priority_fee_gwei: float | None
|
||||
# Recommended
|
||||
slow: dict[str, str]
|
||||
standard: dict[str, str]
|
||||
fast: dict[str, str]
|
||||
|
||||
|
||||
class CreateGasPolicyRequest(BaseModel):
|
||||
"""Create gas policy request."""
|
||||
|
||||
name: str
|
||||
chain: str
|
||||
network: str = "mainnet"
|
||||
rules: dict[str, Any]
|
||||
max_gas_per_op: int = 1000000
|
||||
max_spend_per_day_usd: int = 100 # in cents
|
||||
allowed_contracts: list[str] | None = None
|
||||
allowed_methods: list[str] | None = None
|
||||
|
||||
|
||||
class GasPolicyResponse(BaseModel):
|
||||
"""Gas policy response."""
|
||||
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
chain: str
|
||||
network: str
|
||||
rules: dict[str, Any]
|
||||
max_gas_per_op: int
|
||||
max_spend_per_day_usd: int
|
||||
allowed_contracts: list[str] | None
|
||||
allowed_methods: list[str] | None
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class SponsorRequest(BaseModel):
|
||||
"""Gas sponsorship request."""
|
||||
|
||||
user_op: dict[str, Any]
|
||||
entry_point: str
|
||||
policy_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class SponsorResponse(BaseModel):
|
||||
"""Gas sponsorship response."""
|
||||
|
||||
paymaster_and_data: str
|
||||
pre_verification_gas: str
|
||||
verification_gas_limit: str
|
||||
call_gas_limit: str
|
||||
|
||||
|
||||
@router.get("/{chain}/prices", response_model=GasPrices)
|
||||
async def get_gas_prices(
|
||||
chain: str,
|
||||
api_key: ApiKeyDep,
|
||||
network: str = "mainnet",
|
||||
) -> GasPrices:
|
||||
"""Get current gas prices for a chain."""
|
||||
if not ChainRegistry.is_supported(chain, network):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported chain/network: {chain}/{network}",
|
||||
)
|
||||
|
||||
async with RPCClient(chain, network) as client:
|
||||
# Get legacy gas price
|
||||
gas_price = await client.get_gas_price()
|
||||
gas_price_gwei = gas_price / 1e9
|
||||
|
||||
# Try to get EIP-1559 data
|
||||
base_fee = None
|
||||
base_fee_gwei = None
|
||||
max_priority_fee = None
|
||||
max_priority_fee_gwei = None
|
||||
|
||||
try:
|
||||
block = await client.call("eth_getBlockByNumber", ["latest", False])
|
||||
if block and block.get("baseFeePerGas"):
|
||||
base_fee = int(block["baseFeePerGas"], 16)
|
||||
base_fee_gwei = base_fee / 1e9
|
||||
|
||||
max_priority_fee_result = await client.call("eth_maxPriorityFeePerGas", [])
|
||||
max_priority_fee = int(max_priority_fee_result, 16)
|
||||
max_priority_fee_gwei = max_priority_fee / 1e9
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Calculate recommended prices
|
||||
if base_fee is not None:
|
||||
# EIP-1559 chain
|
||||
slow_priority = max_priority_fee or gas_price // 10
|
||||
standard_priority = int(slow_priority * 1.5)
|
||||
fast_priority = int(slow_priority * 2)
|
||||
|
||||
slow = {
|
||||
"maxFeePerGas": hex(base_fee + slow_priority),
|
||||
"maxPriorityFeePerGas": hex(slow_priority),
|
||||
}
|
||||
standard = {
|
||||
"maxFeePerGas": hex(int(base_fee * 1.1) + standard_priority),
|
||||
"maxPriorityFeePerGas": hex(standard_priority),
|
||||
}
|
||||
fast = {
|
||||
"maxFeePerGas": hex(int(base_fee * 1.25) + fast_priority),
|
||||
"maxPriorityFeePerGas": hex(fast_priority),
|
||||
}
|
||||
else:
|
||||
# Legacy chain
|
||||
slow = {"gasPrice": hex(gas_price)}
|
||||
standard = {"gasPrice": hex(int(gas_price * 1.1))}
|
||||
fast = {"gasPrice": hex(int(gas_price * 1.25))}
|
||||
|
||||
return GasPrices(
|
||||
chain=chain,
|
||||
network=network,
|
||||
timestamp=datetime.utcnow(),
|
||||
gas_price=hex(gas_price),
|
||||
gas_price_gwei=gas_price_gwei,
|
||||
base_fee=hex(base_fee) if base_fee else None,
|
||||
base_fee_gwei=base_fee_gwei,
|
||||
max_priority_fee=hex(max_priority_fee) if max_priority_fee else None,
|
||||
max_priority_fee_gwei=max_priority_fee_gwei,
|
||||
slow=slow,
|
||||
standard=standard,
|
||||
fast=fast,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/policies", response_model=GasPolicyResponse)
|
||||
async def create_gas_policy(
|
||||
request: CreateGasPolicyRequest,
|
||||
api_key: ApiKeyDep,
|
||||
project: ProjectDep,
|
||||
db: DbDep,
|
||||
) -> GasPolicy:
|
||||
"""Create a new gas sponsorship policy."""
|
||||
if not ChainRegistry.is_supported(request.chain, request.network):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported chain/network: {request.chain}/{request.network}",
|
||||
)
|
||||
|
||||
policy = GasPolicy(
|
||||
project_id=project.id,
|
||||
name=request.name,
|
||||
chain=request.chain,
|
||||
network=request.network,
|
||||
rules=request.rules,
|
||||
max_gas_per_op=request.max_gas_per_op,
|
||||
max_spend_per_day_usd=request.max_spend_per_day_usd,
|
||||
allowed_contracts=[c.lower() for c in request.allowed_contracts] if request.allowed_contracts else None,
|
||||
allowed_methods=request.allowed_methods,
|
||||
)
|
||||
|
||||
db.add(policy)
|
||||
await db.commit()
|
||||
await db.refresh(policy)
|
||||
|
||||
return policy
|
||||
|
||||
|
||||
@router.get("/policies", response_model=list[GasPolicyResponse])
|
||||
async def list_gas_policies(
|
||||
api_key: ApiKeyDep,
|
||||
project: ProjectDep,
|
||||
db: DbDep,
|
||||
chain: str | None = None,
|
||||
) -> list[GasPolicy]:
|
||||
"""List all gas policies for the project."""
|
||||
query = select(GasPolicy).where(GasPolicy.project_id == project.id)
|
||||
|
||||
if chain:
|
||||
query = query.where(GasPolicy.chain == chain)
|
||||
|
||||
result = await db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.get("/policies/{policy_id}", response_model=GasPolicyResponse)
|
||||
async def get_gas_policy(
|
||||
policy_id: uuid.UUID,
|
||||
api_key: ApiKeyDep,
|
||||
project: ProjectDep,
|
||||
db: DbDep,
|
||||
) -> GasPolicy:
|
||||
"""Get a specific gas policy."""
|
||||
result = await db.execute(
|
||||
select(GasPolicy).where(
|
||||
GasPolicy.id == policy_id,
|
||||
GasPolicy.project_id == project.id,
|
||||
)
|
||||
)
|
||||
policy = result.scalar_one_or_none()
|
||||
|
||||
if not policy:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Policy not found",
|
||||
)
|
||||
|
||||
return policy
|
||||
|
||||
|
||||
@router.delete("/policies/{policy_id}")
|
||||
async def delete_gas_policy(
|
||||
policy_id: uuid.UUID,
|
||||
api_key: ApiKeyDep,
|
||||
project: ProjectDep,
|
||||
db: DbDep,
|
||||
) -> dict:
|
||||
"""Delete a gas policy."""
|
||||
result = await db.execute(
|
||||
select(GasPolicy).where(
|
||||
GasPolicy.id == policy_id,
|
||||
GasPolicy.project_id == project.id,
|
||||
)
|
||||
)
|
||||
policy = result.scalar_one_or_none()
|
||||
|
||||
if not policy:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Policy not found",
|
||||
)
|
||||
|
||||
await db.delete(policy)
|
||||
await db.commit()
|
||||
|
||||
return {"status": "deleted", "id": str(policy_id)}
|
||||
|
||||
|
||||
@router.post("/sponsor", response_model=SponsorResponse)
|
||||
async def sponsor_user_operation(
|
||||
request: SponsorRequest,
|
||||
api_key: ApiKeyDep,
|
||||
project: ProjectDep,
|
||||
db: DbDep,
|
||||
) -> SponsorResponse:
|
||||
"""
|
||||
Get paymaster data to sponsor a UserOperation.
|
||||
|
||||
This endpoint checks if the UserOp qualifies for sponsorship
|
||||
based on the project's gas policies and returns the paymaster data.
|
||||
"""
|
||||
user_op = request.user_op
|
||||
sender = user_op.get("sender", "").lower()
|
||||
call_data = user_op.get("callData", "0x")
|
||||
|
||||
# Find applicable policy
|
||||
if request.policy_id:
|
||||
result = await db.execute(
|
||||
select(GasPolicy).where(
|
||||
GasPolicy.id == request.policy_id,
|
||||
GasPolicy.project_id == project.id,
|
||||
GasPolicy.is_active == True,
|
||||
)
|
||||
)
|
||||
policy = result.scalar_one_or_none()
|
||||
else:
|
||||
# Find first matching active policy
|
||||
result = await db.execute(
|
||||
select(GasPolicy).where(
|
||||
GasPolicy.project_id == project.id,
|
||||
GasPolicy.is_active == True,
|
||||
)
|
||||
)
|
||||
policy = result.scalars().first()
|
||||
|
||||
if not policy:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No active gas policy found",
|
||||
)
|
||||
|
||||
# Check policy rules
|
||||
# 1. Check allowed contracts
|
||||
if policy.allowed_contracts:
|
||||
# Extract target from callData (first 4 bytes are selector, next is target for execute())
|
||||
# This is simplified - real implementation needs proper decoding
|
||||
target = call_data[10:50] if len(call_data) > 50 else None
|
||||
if target and f"0x{target}" not in policy.allowed_contracts:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Target contract not allowed by policy",
|
||||
)
|
||||
|
||||
# 2. Check gas limits
|
||||
call_gas = int(user_op.get("callGasLimit", "0x0"), 16)
|
||||
verification_gas = int(user_op.get("verificationGasLimit", "0x0"), 16)
|
||||
total_gas = call_gas + verification_gas
|
||||
|
||||
if total_gas > policy.max_gas_per_op:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Gas exceeds policy limit ({total_gas} > {policy.max_gas_per_op})",
|
||||
)
|
||||
|
||||
# In production, this would:
|
||||
# 1. Sign the UserOp with the paymaster's key
|
||||
# 2. Return proper paymasterAndData with signature
|
||||
# 3. Track spending against daily limits
|
||||
|
||||
# Simplified response
|
||||
return SponsorResponse(
|
||||
paymaster_and_data="0x", # Would be actual paymaster + signature
|
||||
pre_verification_gas=user_op.get("preVerificationGas", "0x5208"),
|
||||
verification_gas_limit=user_op.get("verificationGasLimit", "0x186a0"),
|
||||
call_gas_limit=user_op.get("callGasLimit", "0x186a0"),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/estimate")
|
||||
async def estimate_gas(
|
||||
chain: str,
|
||||
to: str,
|
||||
api_key: ApiKeyDep,
|
||||
network: str = "mainnet",
|
||||
data: str = "0x",
|
||||
value: str = "0x0",
|
||||
from_address: str | None = None,
|
||||
) -> dict:
|
||||
"""Estimate gas for a transaction."""
|
||||
if not ChainRegistry.is_supported(chain, network):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported chain/network: {chain}/{network}",
|
||||
)
|
||||
|
||||
async with RPCClient(chain, network) as client:
|
||||
gas_estimate = await client.estimate_gas(
|
||||
to=to,
|
||||
data=data if data != "0x" else None,
|
||||
from_address=from_address,
|
||||
value=int(value, 16) if value != "0x0" else None,
|
||||
)
|
||||
|
||||
gas_price = await client.get_gas_price()
|
||||
|
||||
return {
|
||||
"gas_estimate": gas_estimate,
|
||||
"gas_estimate_hex": hex(gas_estimate),
|
||||
"gas_price": gas_price,
|
||||
"gas_price_hex": hex(gas_price),
|
||||
"estimated_cost_wei": gas_estimate * gas_price,
|
||||
"estimated_cost_gwei": (gas_estimate * gas_price) / 1e9,
|
||||
"estimated_cost_eth": (gas_estimate * gas_price) / 1e18,
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
"""NFT API - ERC-721/ERC-1155 NFT data."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from bootnode.api.deps import ApiKeyDep
|
||||
from bootnode.core.chains import ChainRegistry, RPCClient
|
||||
from bootnode.config import get_settings
|
||||
|
||||
router = APIRouter()
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
# ERC-721 function signatures
|
||||
ERC721_BALANCE_OF = "0x70a08231"
|
||||
ERC721_OWNER_OF = "0x6352211e"
|
||||
ERC721_TOKEN_URI = "0xc87b56dd"
|
||||
ERC721_NAME = "0x06fdde03"
|
||||
ERC721_SYMBOL = "0x95d89b41"
|
||||
|
||||
# ERC-1155 function signatures
|
||||
ERC1155_BALANCE_OF = "0x00fdd58e"
|
||||
ERC1155_URI = "0x0e89341c"
|
||||
|
||||
|
||||
class NFTMetadata(BaseModel):
|
||||
"""NFT metadata."""
|
||||
|
||||
contract_address: str
|
||||
token_id: str
|
||||
token_type: str # ERC721 or ERC1155
|
||||
name: str | None
|
||||
description: str | None
|
||||
image: str | None
|
||||
animation_url: str | None
|
||||
external_url: str | None
|
||||
attributes: list[dict[str, Any]] | None
|
||||
raw_metadata: dict[str, Any] | None
|
||||
|
||||
|
||||
class NFTCollection(BaseModel):
|
||||
"""NFT collection info."""
|
||||
|
||||
contract_address: str
|
||||
name: str | None
|
||||
symbol: str | None
|
||||
token_type: str
|
||||
total_supply: str | None
|
||||
|
||||
|
||||
class OwnedNFT(BaseModel):
|
||||
"""Owned NFT."""
|
||||
|
||||
contract_address: str
|
||||
token_id: str
|
||||
token_type: str
|
||||
balance: str # For ERC-1155
|
||||
name: str | None
|
||||
image: str | None
|
||||
|
||||
|
||||
class OwnedNFTsResponse(BaseModel):
|
||||
"""Owned NFTs response."""
|
||||
|
||||
address: str
|
||||
chain: str
|
||||
network: str
|
||||
nfts: list[OwnedNFT]
|
||||
page: int
|
||||
page_size: int
|
||||
total: int | None
|
||||
|
||||
|
||||
@router.get("/{chain}/metadata/{contract}/{token_id}", response_model=NFTMetadata)
|
||||
async def get_nft_metadata(
|
||||
chain: str,
|
||||
contract: str,
|
||||
token_id: str,
|
||||
api_key: ApiKeyDep,
|
||||
network: str = "mainnet",
|
||||
) -> NFTMetadata:
|
||||
"""Get metadata for a specific NFT."""
|
||||
if not ChainRegistry.is_supported(chain, network):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported chain/network: {chain}/{network}",
|
||||
)
|
||||
|
||||
contract = contract.lower()
|
||||
|
||||
async with RPCClient(chain, network) as client:
|
||||
# Try ERC-721 first
|
||||
token_uri = await _get_token_uri_721(client, contract, token_id)
|
||||
token_type = "ERC721"
|
||||
|
||||
if not token_uri:
|
||||
# Try ERC-1155
|
||||
token_uri = await _get_token_uri_1155(client, contract, token_id)
|
||||
token_type = "ERC1155"
|
||||
|
||||
metadata = None
|
||||
if token_uri:
|
||||
metadata = await _fetch_metadata(token_uri)
|
||||
|
||||
return NFTMetadata(
|
||||
contract_address=contract,
|
||||
token_id=token_id,
|
||||
token_type=token_type,
|
||||
name=metadata.get("name") if metadata else None,
|
||||
description=metadata.get("description") if metadata else None,
|
||||
image=_resolve_ipfs(metadata.get("image")) if metadata else None,
|
||||
animation_url=_resolve_ipfs(metadata.get("animation_url")) if metadata else None,
|
||||
external_url=metadata.get("external_url") if metadata else None,
|
||||
attributes=metadata.get("attributes") if metadata else None,
|
||||
raw_metadata=metadata,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{chain}/collection/{contract}", response_model=NFTCollection)
|
||||
async def get_nft_collection(
|
||||
chain: str,
|
||||
contract: str,
|
||||
api_key: ApiKeyDep,
|
||||
network: str = "mainnet",
|
||||
) -> NFTCollection:
|
||||
"""Get NFT collection information."""
|
||||
if not ChainRegistry.is_supported(chain, network):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported chain/network: {chain}/{network}",
|
||||
)
|
||||
|
||||
contract = contract.lower()
|
||||
|
||||
async with RPCClient(chain, network) as client:
|
||||
name = await _get_contract_string(client, contract, ERC721_NAME)
|
||||
symbol = await _get_contract_string(client, contract, ERC721_SYMBOL)
|
||||
|
||||
# Determine token type by checking supportsInterface
|
||||
# For simplicity, default to ERC721
|
||||
token_type = "ERC721"
|
||||
|
||||
return NFTCollection(
|
||||
contract_address=contract,
|
||||
name=name,
|
||||
symbol=symbol,
|
||||
token_type=token_type,
|
||||
total_supply=None, # Would need to call totalSupply if available
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{chain}/owned/{address}", response_model=OwnedNFTsResponse)
|
||||
async def get_owned_nfts(
|
||||
chain: str,
|
||||
address: str,
|
||||
api_key: ApiKeyDep,
|
||||
network: str = "mainnet",
|
||||
contracts: list[str] | None = Query(default=None),
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
) -> OwnedNFTsResponse:
|
||||
"""
|
||||
Get NFTs owned by an address.
|
||||
|
||||
Note: Full NFT discovery requires indexing. If contracts are provided,
|
||||
we check balances for those specific contracts.
|
||||
"""
|
||||
if not ChainRegistry.is_supported(chain, network):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported chain/network: {chain}/{network}",
|
||||
)
|
||||
|
||||
address = address.lower()
|
||||
|
||||
# Without indexing, we can only check specific contracts
|
||||
nfts: list[OwnedNFT] = []
|
||||
|
||||
if contracts:
|
||||
async with RPCClient(chain, network) as client:
|
||||
for contract in contracts[:20]: # Limit to 20 contracts
|
||||
contract = contract.lower()
|
||||
try:
|
||||
# Check ERC-721 balance
|
||||
balance = await _get_721_balance(client, contract, address)
|
||||
if balance and balance > 0:
|
||||
name = await _get_contract_string(client, contract, ERC721_NAME)
|
||||
# Note: Getting specific token IDs requires Transfer event indexing
|
||||
nfts.append(OwnedNFT(
|
||||
contract_address=contract,
|
||||
token_id="*", # Unknown without indexing
|
||||
token_type="ERC721",
|
||||
balance=str(balance),
|
||||
name=name,
|
||||
image=None,
|
||||
))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return OwnedNFTsResponse(
|
||||
address=address,
|
||||
chain=chain,
|
||||
network=network,
|
||||
nfts=nfts,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total=len(nfts),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{chain}/refresh/{contract}/{token_id}")
|
||||
async def refresh_nft_metadata(
|
||||
chain: str,
|
||||
contract: str,
|
||||
token_id: str,
|
||||
api_key: ApiKeyDep,
|
||||
network: str = "mainnet",
|
||||
) -> dict:
|
||||
"""Request a metadata refresh for an NFT."""
|
||||
# In production, this would queue a background job
|
||||
return {
|
||||
"status": "queued",
|
||||
"contract": contract,
|
||||
"token_id": token_id,
|
||||
"message": "Metadata refresh has been queued",
|
||||
}
|
||||
|
||||
|
||||
async def _get_token_uri_721(client: RPCClient, contract: str, token_id: str) -> str | None:
|
||||
"""Get tokenURI for ERC-721."""
|
||||
try:
|
||||
token_id_hex = hex(int(token_id))[2:].zfill(64)
|
||||
result = await client.call_contract(contract, f"{ERC721_TOKEN_URI}{token_id_hex}")
|
||||
if result and result != "0x":
|
||||
return _decode_string(result)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
async def _get_token_uri_1155(client: RPCClient, contract: str, token_id: str) -> str | None:
|
||||
"""Get URI for ERC-1155."""
|
||||
try:
|
||||
token_id_hex = hex(int(token_id))[2:].zfill(64)
|
||||
result = await client.call_contract(contract, f"{ERC1155_URI}{token_id_hex}")
|
||||
if result and result != "0x":
|
||||
uri = _decode_string(result)
|
||||
# ERC-1155 URIs may have {id} placeholder
|
||||
if uri and "{id}" in uri:
|
||||
uri = uri.replace("{id}", token_id_hex)
|
||||
return uri
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
async def _get_contract_string(client: RPCClient, contract: str, selector: str) -> str | None:
|
||||
"""Get a string value from a contract."""
|
||||
try:
|
||||
result = await client.call_contract(contract, selector)
|
||||
if result and result != "0x":
|
||||
return _decode_string(result)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
async def _get_721_balance(client: RPCClient, contract: str, address: str) -> int | None:
|
||||
"""Get ERC-721 balance."""
|
||||
try:
|
||||
address_padded = address[2:].zfill(64)
|
||||
result = await client.call_contract(contract, f"{ERC721_BALANCE_OF}{address_padded}")
|
||||
if result and result != "0x":
|
||||
return int(result, 16)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _decode_string(hex_data: str) -> str | None:
|
||||
"""Decode an ABI-encoded string."""
|
||||
try:
|
||||
data = hex_data[2:] if hex_data.startswith("0x") else hex_data
|
||||
|
||||
if len(data) == 64:
|
||||
decoded = bytes.fromhex(data).rstrip(b'\x00').decode('utf-8', errors='ignore')
|
||||
if decoded.isprintable():
|
||||
return decoded
|
||||
|
||||
if len(data) >= 128:
|
||||
length = int(data[64:128], 16)
|
||||
string_data = data[128:128 + length * 2]
|
||||
return bytes.fromhex(string_data).decode('utf-8', errors='ignore')
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
async def _fetch_metadata(uri: str) -> dict[str, Any] | None:
|
||||
"""Fetch metadata from URI."""
|
||||
import httpx
|
||||
|
||||
try:
|
||||
resolved_uri = _resolve_ipfs(uri)
|
||||
if not resolved_uri:
|
||||
return None
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(resolved_uri)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_ipfs(uri: str | None) -> str | None:
|
||||
"""Resolve IPFS URIs to HTTP gateway."""
|
||||
if not uri:
|
||||
return None
|
||||
|
||||
if uri.startswith("ipfs://"):
|
||||
return f"{settings.ipfs_gateway}{uri[7:]}"
|
||||
|
||||
if uri.startswith("ar://"):
|
||||
return f"https://arweave.net/{uri[5:]}"
|
||||
|
||||
return uri
|
||||
@@ -0,0 +1,235 @@
|
||||
"""RPC API - JSON-RPC proxy."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from bootnode.api.deps import ApiKeyDep, DbDep
|
||||
from bootnode.core.chains import ChainRegistry, RPCClient
|
||||
from bootnode.core.chains.rpc import get_compute_units, RPCError
|
||||
from bootnode.core.cache import redis_client
|
||||
from bootnode.db.models import Usage
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class JsonRpcRequest(BaseModel):
|
||||
"""JSON-RPC 2.0 request."""
|
||||
|
||||
jsonrpc: str = "2.0"
|
||||
method: str
|
||||
params: list[Any] | dict[str, Any] | None = None
|
||||
id: int | str | None = 1
|
||||
|
||||
|
||||
class JsonRpcResponse(BaseModel):
|
||||
"""JSON-RPC 2.0 response."""
|
||||
|
||||
jsonrpc: str = "2.0"
|
||||
result: Any | None = None
|
||||
error: dict[str, Any] | None = None
|
||||
id: int | str | None = None
|
||||
|
||||
|
||||
class JsonRpcBatchResponse(BaseModel):
|
||||
"""Batch JSON-RPC response."""
|
||||
|
||||
responses: list[JsonRpcResponse]
|
||||
|
||||
|
||||
# Cache TTLs by method (in seconds)
|
||||
CACHE_TTLS: dict[str, int] = {
|
||||
"eth_chainId": 86400, # 1 day
|
||||
"net_version": 86400,
|
||||
"web3_clientVersion": 3600,
|
||||
"eth_blockNumber": 2, # ~1 block
|
||||
"eth_gasPrice": 5,
|
||||
"eth_maxPriorityFeePerGas": 5,
|
||||
"eth_getBlockByNumber": 60, # Finalized blocks don't change
|
||||
"eth_getBlockByHash": 3600,
|
||||
"eth_getTransactionByHash": 3600,
|
||||
"eth_getTransactionReceipt": 3600,
|
||||
}
|
||||
|
||||
# Methods that should not be cached
|
||||
NO_CACHE_METHODS = {
|
||||
"eth_sendRawTransaction",
|
||||
"eth_sendTransaction",
|
||||
"eth_call", # Can have state-dependent results
|
||||
"eth_estimateGas",
|
||||
"eth_getBalance", # Real-time balance needed
|
||||
"eth_getTransactionCount", # Nonce matters
|
||||
}
|
||||
|
||||
|
||||
def get_params_hash(params: list[Any] | dict[str, Any] | None) -> str:
|
||||
"""Get a hash of the params for caching."""
|
||||
if not params:
|
||||
return "none"
|
||||
return hashlib.md5(json.dumps(params, sort_keys=True).encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
@router.post("/{chain}")
|
||||
@router.post("/{chain}/{network}")
|
||||
async def rpc_call(
|
||||
chain: str,
|
||||
request: JsonRpcRequest | list[JsonRpcRequest],
|
||||
api_key: ApiKeyDep,
|
||||
db: DbDep,
|
||||
network: str = "mainnet",
|
||||
) -> JsonRpcResponse | list[JsonRpcResponse]:
|
||||
"""
|
||||
Make a JSON-RPC call to a blockchain.
|
||||
|
||||
Supports both single requests and batch requests.
|
||||
"""
|
||||
# Validate chain/network
|
||||
if not ChainRegistry.is_supported(chain, network):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported chain/network: {chain}/{network}",
|
||||
)
|
||||
|
||||
# Check if chain is allowed for this API key
|
||||
if api_key.allowed_chains and chain not in api_key.allowed_chains:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Chain '{chain}' not allowed for this API key",
|
||||
)
|
||||
|
||||
# Handle batch requests
|
||||
if isinstance(request, list):
|
||||
return await _handle_batch(chain, network, request, api_key, db)
|
||||
|
||||
return await _handle_single(chain, network, request, api_key, db)
|
||||
|
||||
|
||||
async def _handle_single(
|
||||
chain: str,
|
||||
network: str,
|
||||
request: JsonRpcRequest,
|
||||
api_key: ApiKeyDep,
|
||||
db: DbDep,
|
||||
) -> JsonRpcResponse:
|
||||
"""Handle a single RPC request."""
|
||||
method = request.method
|
||||
params = request.params if request.params else []
|
||||
params_list = params if isinstance(params, list) else [params]
|
||||
|
||||
start_time = time.time()
|
||||
compute_units = get_compute_units(method)
|
||||
|
||||
# Check cache first (for cacheable methods)
|
||||
cache_key = None
|
||||
if method not in NO_CACHE_METHODS:
|
||||
params_hash = get_params_hash(params)
|
||||
cached = await redis_client.get_rpc_response(chain, network, method, params_hash)
|
||||
if cached is not None:
|
||||
# Record usage (even cached)
|
||||
response_time = int((time.time() - start_time) * 1000)
|
||||
await _record_usage(db, api_key, chain, network, method, compute_units, response_time)
|
||||
return JsonRpcResponse(
|
||||
jsonrpc="2.0",
|
||||
result=cached,
|
||||
id=request.id,
|
||||
)
|
||||
cache_key = params_hash
|
||||
|
||||
# Make the RPC call
|
||||
try:
|
||||
async with RPCClient(chain, network) as client:
|
||||
result = await client.call(method, params_list, request.id or 1)
|
||||
|
||||
# Cache the result if applicable
|
||||
if cache_key and method in CACHE_TTLS:
|
||||
ttl = CACHE_TTLS[method]
|
||||
await redis_client.set_rpc_response(chain, network, method, cache_key, result, ttl)
|
||||
|
||||
# Record usage
|
||||
response_time = int((time.time() - start_time) * 1000)
|
||||
await _record_usage(db, api_key, chain, network, method, compute_units, response_time)
|
||||
|
||||
return JsonRpcResponse(
|
||||
jsonrpc="2.0",
|
||||
result=result,
|
||||
id=request.id,
|
||||
)
|
||||
|
||||
except RPCError as e:
|
||||
response_time = int((time.time() - start_time) * 1000)
|
||||
await _record_usage(db, api_key, chain, network, method, compute_units, response_time, 500)
|
||||
|
||||
return JsonRpcResponse(
|
||||
jsonrpc="2.0",
|
||||
error={
|
||||
"code": e.code,
|
||||
"message": e.message,
|
||||
"data": e.data,
|
||||
},
|
||||
id=request.id,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
response_time = int((time.time() - start_time) * 1000)
|
||||
await _record_usage(db, api_key, chain, network, method, compute_units, response_time, 500)
|
||||
|
||||
return JsonRpcResponse(
|
||||
jsonrpc="2.0",
|
||||
error={
|
||||
"code": -32603,
|
||||
"message": f"Internal error: {str(e)}",
|
||||
},
|
||||
id=request.id,
|
||||
)
|
||||
|
||||
|
||||
async def _handle_batch(
|
||||
chain: str,
|
||||
network: str,
|
||||
requests: list[JsonRpcRequest],
|
||||
api_key: ApiKeyDep,
|
||||
db: DbDep,
|
||||
) -> list[JsonRpcResponse]:
|
||||
"""Handle a batch of RPC requests."""
|
||||
# Limit batch size
|
||||
if len(requests) > 100:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Batch size cannot exceed 100 requests",
|
||||
)
|
||||
|
||||
responses = []
|
||||
for req in requests:
|
||||
response = await _handle_single(chain, network, req, api_key, db)
|
||||
responses.append(response)
|
||||
|
||||
return responses
|
||||
|
||||
|
||||
async def _record_usage(
|
||||
db: DbDep,
|
||||
api_key: ApiKeyDep,
|
||||
chain: str,
|
||||
network: str,
|
||||
method: str,
|
||||
compute_units: int,
|
||||
response_time: int,
|
||||
status_code: int = 200,
|
||||
) -> None:
|
||||
"""Record API usage."""
|
||||
usage = Usage(
|
||||
project_id=api_key.project_id,
|
||||
api_key_id=api_key.id,
|
||||
chain=chain,
|
||||
network=network,
|
||||
method=method,
|
||||
compute_units=compute_units,
|
||||
response_time_ms=response_time,
|
||||
status_code=status_code,
|
||||
)
|
||||
db.add(usage)
|
||||
# Don't await commit here - let the session handle it
|
||||
@@ -0,0 +1,235 @@
|
||||
"""Token API - ERC-20/ERC-721/ERC-1155 token data."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from bootnode.api.deps import ApiKeyDep
|
||||
from bootnode.core.chains import ChainRegistry, RPCClient
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# Common ERC-20 ABI function signatures
|
||||
ERC20_BALANCE_OF = "0x70a08231" # balanceOf(address)
|
||||
ERC20_DECIMALS = "0x313ce567" # decimals()
|
||||
ERC20_SYMBOL = "0x95d89b41" # symbol()
|
||||
ERC20_NAME = "0x06fdde03" # name()
|
||||
ERC20_TOTAL_SUPPLY = "0x18160ddd" # totalSupply()
|
||||
|
||||
|
||||
class TokenBalance(BaseModel):
|
||||
"""Token balance response."""
|
||||
|
||||
contract_address: str
|
||||
token_type: str # ERC20, ERC721, ERC1155
|
||||
name: str | None
|
||||
symbol: str | None
|
||||
decimals: int | None
|
||||
balance: str # Raw balance as string
|
||||
balance_formatted: str | None # Human-readable
|
||||
|
||||
|
||||
class TokenMetadata(BaseModel):
|
||||
"""Token metadata response."""
|
||||
|
||||
contract_address: str
|
||||
name: str | None
|
||||
symbol: str | None
|
||||
decimals: int | None
|
||||
total_supply: str | None
|
||||
logo_url: str | None
|
||||
|
||||
|
||||
class BalancesResponse(BaseModel):
|
||||
"""Token balances response."""
|
||||
|
||||
address: str
|
||||
chain: str
|
||||
network: str
|
||||
native_balance: str
|
||||
native_balance_formatted: str
|
||||
tokens: list[TokenBalance]
|
||||
|
||||
|
||||
@router.get("/{chain}/balances/{address}", response_model=BalancesResponse)
|
||||
async def get_token_balances(
|
||||
chain: str,
|
||||
address: str,
|
||||
api_key: ApiKeyDep,
|
||||
network: str = "mainnet",
|
||||
token_addresses: list[str] | None = Query(default=None),
|
||||
) -> BalancesResponse:
|
||||
"""
|
||||
Get token balances for an address.
|
||||
|
||||
If token_addresses is provided, only those tokens are queried.
|
||||
Otherwise, returns native balance only (full token discovery requires indexing).
|
||||
"""
|
||||
if not ChainRegistry.is_supported(chain, network):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported chain/network: {chain}/{network}",
|
||||
)
|
||||
|
||||
# Normalize address
|
||||
address = address.lower()
|
||||
if not address.startswith("0x") or len(address) != 42:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid address format",
|
||||
)
|
||||
|
||||
chain_config = ChainRegistry.get_chain(chain)
|
||||
network_config = chain_config.networks.get(network) if chain_config else None
|
||||
|
||||
async with RPCClient(chain, network) as client:
|
||||
# Get native balance
|
||||
native_balance = await client.get_balance(address)
|
||||
native_decimals = network_config.native_decimals if network_config else 18
|
||||
native_formatted = _format_balance(native_balance, native_decimals)
|
||||
|
||||
tokens = []
|
||||
|
||||
# Get specific token balances if requested
|
||||
if token_addresses:
|
||||
for token_addr in token_addresses[:50]: # Limit to 50 tokens
|
||||
token_addr = token_addr.lower()
|
||||
try:
|
||||
# Get balance
|
||||
balance_data = address[2:].zfill(64)
|
||||
balance_result = await client.call_contract(
|
||||
token_addr,
|
||||
f"{ERC20_BALANCE_OF}000000000000000000000000{balance_data}",
|
||||
)
|
||||
balance = int(balance_result, 16) if balance_result != "0x" else 0
|
||||
|
||||
# Get metadata (batch call would be more efficient)
|
||||
name = await _get_token_string(client, token_addr, ERC20_NAME)
|
||||
symbol = await _get_token_string(client, token_addr, ERC20_SYMBOL)
|
||||
decimals = await _get_token_decimals(client, token_addr)
|
||||
|
||||
tokens.append(TokenBalance(
|
||||
contract_address=token_addr,
|
||||
token_type="ERC20",
|
||||
name=name,
|
||||
symbol=symbol,
|
||||
decimals=decimals,
|
||||
balance=str(balance),
|
||||
balance_formatted=_format_balance(balance, decimals or 18),
|
||||
))
|
||||
except Exception:
|
||||
# Skip tokens that fail
|
||||
continue
|
||||
|
||||
return BalancesResponse(
|
||||
address=address,
|
||||
chain=chain,
|
||||
network=network,
|
||||
native_balance=str(native_balance),
|
||||
native_balance_formatted=native_formatted,
|
||||
tokens=tokens,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{chain}/metadata/{contract}", response_model=TokenMetadata)
|
||||
async def get_token_metadata(
|
||||
chain: str,
|
||||
contract: str,
|
||||
api_key: ApiKeyDep,
|
||||
network: str = "mainnet",
|
||||
) -> TokenMetadata:
|
||||
"""Get metadata for a token contract."""
|
||||
if not ChainRegistry.is_supported(chain, network):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported chain/network: {chain}/{network}",
|
||||
)
|
||||
|
||||
contract = contract.lower()
|
||||
|
||||
async with RPCClient(chain, network) as client:
|
||||
name = await _get_token_string(client, contract, ERC20_NAME)
|
||||
symbol = await _get_token_string(client, contract, ERC20_SYMBOL)
|
||||
decimals = await _get_token_decimals(client, contract)
|
||||
|
||||
# Get total supply
|
||||
try:
|
||||
supply_result = await client.call_contract(contract, ERC20_TOTAL_SUPPLY)
|
||||
total_supply = str(int(supply_result, 16)) if supply_result != "0x" else None
|
||||
except Exception:
|
||||
total_supply = None
|
||||
|
||||
return TokenMetadata(
|
||||
contract_address=contract,
|
||||
name=name,
|
||||
symbol=symbol,
|
||||
decimals=decimals,
|
||||
total_supply=total_supply,
|
||||
logo_url=None, # Would need external source
|
||||
)
|
||||
|
||||
|
||||
async def _get_token_string(client: RPCClient, contract: str, selector: str) -> str | None:
|
||||
"""Get a string value from a token contract."""
|
||||
try:
|
||||
result = await client.call_contract(contract, selector)
|
||||
if result and result != "0x":
|
||||
# Decode string from ABI encoding
|
||||
return _decode_string(result)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
async def _get_token_decimals(client: RPCClient, contract: str) -> int | None:
|
||||
"""Get decimals from a token contract."""
|
||||
try:
|
||||
result = await client.call_contract(contract, ERC20_DECIMALS)
|
||||
if result and result != "0x":
|
||||
return int(result, 16)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _decode_string(hex_data: str) -> str | None:
|
||||
"""Decode an ABI-encoded string."""
|
||||
try:
|
||||
# Remove 0x prefix
|
||||
data = hex_data[2:] if hex_data.startswith("0x") else hex_data
|
||||
|
||||
# Handle short strings (packed in 32 bytes)
|
||||
if len(data) == 64:
|
||||
# Check if it's a short string
|
||||
decoded = bytes.fromhex(data).rstrip(b'\x00').decode('utf-8', errors='ignore')
|
||||
if decoded.isprintable():
|
||||
return decoded
|
||||
|
||||
# Handle dynamic strings
|
||||
if len(data) >= 128:
|
||||
# Skip offset (32 bytes) and get length (32 bytes)
|
||||
length = int(data[64:128], 16)
|
||||
# Get string data
|
||||
string_data = data[128:128 + length * 2]
|
||||
return bytes.fromhex(string_data).decode('utf-8', errors='ignore')
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _format_balance(balance: int, decimals: int) -> str:
|
||||
"""Format a balance with decimals."""
|
||||
if decimals == 0:
|
||||
return str(balance)
|
||||
|
||||
balance_str = str(balance).zfill(decimals + 1)
|
||||
integer_part = balance_str[:-decimals] or "0"
|
||||
decimal_part = balance_str[-decimals:].rstrip("0") or "0"
|
||||
|
||||
if decimal_part == "0":
|
||||
return integer_part
|
||||
|
||||
return f"{integer_part}.{decimal_part}"
|
||||
@@ -0,0 +1,232 @@
|
||||
"""Transfers API - Transaction history and transfers."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from bootnode.api.deps import ApiKeyDep
|
||||
from bootnode.core.chains import ChainRegistry, RPCClient
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class Transfer(BaseModel):
|
||||
"""Transfer/transaction record."""
|
||||
|
||||
hash: str
|
||||
block_number: int
|
||||
block_hash: str
|
||||
timestamp: datetime | None
|
||||
from_address: str
|
||||
to_address: str | None
|
||||
value: str
|
||||
gas_used: int | None
|
||||
gas_price: str | None
|
||||
status: str # success, failed, pending
|
||||
type: str # native, erc20, erc721, erc1155, internal
|
||||
token_address: str | None
|
||||
token_id: str | None
|
||||
|
||||
|
||||
class TransfersResponse(BaseModel):
|
||||
"""Transfers response."""
|
||||
|
||||
address: str
|
||||
chain: str
|
||||
network: str
|
||||
transfers: list[Transfer]
|
||||
page: int
|
||||
page_size: int
|
||||
has_more: bool
|
||||
|
||||
|
||||
class TransactionDetails(BaseModel):
|
||||
"""Full transaction details."""
|
||||
|
||||
hash: str
|
||||
block_number: int | None
|
||||
block_hash: str | None
|
||||
from_address: str
|
||||
to_address: str | None
|
||||
value: str
|
||||
gas: int
|
||||
gas_price: str
|
||||
max_fee_per_gas: str | None
|
||||
max_priority_fee_per_gas: str | None
|
||||
nonce: int
|
||||
input: str
|
||||
status: str
|
||||
gas_used: int | None
|
||||
effective_gas_price: str | None
|
||||
logs: list[dict[str, Any]] | None
|
||||
|
||||
|
||||
@router.get("/{chain}/address/{address}", response_model=TransfersResponse)
|
||||
async def get_transfers(
|
||||
chain: str,
|
||||
address: str,
|
||||
api_key: ApiKeyDep,
|
||||
network: str = "mainnet",
|
||||
from_block: int | None = None,
|
||||
to_block: int | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
) -> TransfersResponse:
|
||||
"""
|
||||
Get transfers for an address.
|
||||
|
||||
Note: Full transfer history requires indexing. This endpoint provides
|
||||
limited functionality by querying recent blocks for Transfer events.
|
||||
"""
|
||||
if not ChainRegistry.is_supported(chain, network):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported chain/network: {chain}/{network}",
|
||||
)
|
||||
|
||||
address = address.lower()
|
||||
|
||||
# Without a full indexer, we can only fetch recent transfers via logs
|
||||
# This is a simplified implementation
|
||||
transfers: list[Transfer] = []
|
||||
|
||||
async with RPCClient(chain, network) as client:
|
||||
current_block = await client.get_block_number()
|
||||
|
||||
# Default to last 1000 blocks if not specified
|
||||
if to_block is None:
|
||||
to_block = current_block
|
||||
if from_block is None:
|
||||
from_block = max(0, to_block - 1000)
|
||||
|
||||
# Limit range to prevent excessive queries
|
||||
if to_block - from_block > 10000:
|
||||
from_block = to_block - 10000
|
||||
|
||||
# Get ERC-20 Transfer events
|
||||
# Transfer(address indexed from, address indexed to, uint256 value)
|
||||
transfer_topic = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
|
||||
address_padded = "0x000000000000000000000000" + address[2:]
|
||||
|
||||
try:
|
||||
# Get transfers TO address
|
||||
logs_to = await client.get_logs(
|
||||
from_block=from_block,
|
||||
to_block=to_block,
|
||||
topics=[transfer_topic, None, address_padded],
|
||||
)
|
||||
|
||||
# Get transfers FROM address
|
||||
logs_from = await client.get_logs(
|
||||
from_block=from_block,
|
||||
to_block=to_block,
|
||||
topics=[transfer_topic, address_padded, None],
|
||||
)
|
||||
|
||||
all_logs = logs_to + logs_from
|
||||
|
||||
for log in all_logs[:page_size]:
|
||||
transfers.append(Transfer(
|
||||
hash=log["transactionHash"],
|
||||
block_number=int(log["blockNumber"], 16),
|
||||
block_hash=log["blockHash"],
|
||||
timestamp=None, # Would need to fetch block for timestamp
|
||||
from_address="0x" + log["topics"][1][-40:] if len(log["topics"]) > 1 else "",
|
||||
to_address="0x" + log["topics"][2][-40:] if len(log["topics"]) > 2 else None,
|
||||
value=str(int(log["data"], 16)) if log["data"] != "0x" else "0",
|
||||
gas_used=None,
|
||||
gas_price=None,
|
||||
status="success",
|
||||
type="erc20",
|
||||
token_address=log["address"],
|
||||
token_id=None,
|
||||
))
|
||||
|
||||
except Exception:
|
||||
# Log query may fail for various reasons
|
||||
pass
|
||||
|
||||
return TransfersResponse(
|
||||
address=address,
|
||||
chain=chain,
|
||||
network=network,
|
||||
transfers=transfers,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
has_more=len(transfers) >= page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{chain}/tx/{tx_hash}", response_model=TransactionDetails)
|
||||
async def get_transaction(
|
||||
chain: str,
|
||||
tx_hash: str,
|
||||
api_key: ApiKeyDep,
|
||||
network: str = "mainnet",
|
||||
) -> TransactionDetails:
|
||||
"""Get detailed information about a transaction."""
|
||||
if not ChainRegistry.is_supported(chain, network):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported chain/network: {chain}/{network}",
|
||||
)
|
||||
|
||||
async with RPCClient(chain, network) as client:
|
||||
tx = await client.get_transaction(tx_hash)
|
||||
receipt = await client.get_transaction_receipt(tx_hash)
|
||||
|
||||
if not tx:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Transaction not found",
|
||||
)
|
||||
|
||||
status_str = "pending"
|
||||
if receipt:
|
||||
status_str = "success" if receipt.get("status") == "0x1" else "failed"
|
||||
|
||||
return TransactionDetails(
|
||||
hash=tx["hash"],
|
||||
block_number=int(tx["blockNumber"], 16) if tx.get("blockNumber") else None,
|
||||
block_hash=tx.get("blockHash"),
|
||||
from_address=tx["from"],
|
||||
to_address=tx.get("to"),
|
||||
value=str(int(tx["value"], 16)),
|
||||
gas=int(tx["gas"], 16),
|
||||
gas_price=str(int(tx.get("gasPrice", "0x0"), 16)),
|
||||
max_fee_per_gas=str(int(tx["maxFeePerGas"], 16)) if tx.get("maxFeePerGas") else None,
|
||||
max_priority_fee_per_gas=str(int(tx["maxPriorityFeePerGas"], 16)) if tx.get("maxPriorityFeePerGas") else None,
|
||||
nonce=int(tx["nonce"], 16),
|
||||
input=tx.get("input", "0x"),
|
||||
status=status_str,
|
||||
gas_used=int(receipt["gasUsed"], 16) if receipt else None,
|
||||
effective_gas_price=str(int(receipt["effectiveGasPrice"], 16)) if receipt and receipt.get("effectiveGasPrice") else None,
|
||||
logs=receipt.get("logs") if receipt else None,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{chain}/send")
|
||||
async def send_transaction(
|
||||
chain: str,
|
||||
api_key: ApiKeyDep,
|
||||
signed_tx: str,
|
||||
network: str = "mainnet",
|
||||
) -> dict:
|
||||
"""Send a signed transaction."""
|
||||
if not ChainRegistry.is_supported(chain, network):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported chain/network: {chain}/{network}",
|
||||
)
|
||||
|
||||
async with RPCClient(chain, network) as client:
|
||||
tx_hash = await client.send_raw_transaction(signed_tx)
|
||||
|
||||
return {
|
||||
"hash": tx_hash,
|
||||
"chain": chain,
|
||||
"network": network,
|
||||
"status": "pending",
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
"""Smart Wallets API - ERC-4337 Account Abstraction."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
|
||||
from bootnode.api.deps import ApiKeyDep, DbDep, ProjectDep
|
||||
from bootnode.db.models import SmartWallet
|
||||
from bootnode.core.chains import ChainRegistry, RPCClient
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# Common SimpleAccount factory addresses
|
||||
SIMPLE_ACCOUNT_FACTORY = {
|
||||
"ethereum": "0x9406Cc6185a346906296840746125a0E44976454",
|
||||
"polygon": "0x9406Cc6185a346906296840746125a0E44976454",
|
||||
"arbitrum": "0x9406Cc6185a346906296840746125a0E44976454",
|
||||
"optimism": "0x9406Cc6185a346906296840746125a0E44976454",
|
||||
"base": "0x9406Cc6185a346906296840746125a0E44976454",
|
||||
}
|
||||
|
||||
# EntryPoint v0.6
|
||||
ENTRY_POINT_V06 = "0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789"
|
||||
# EntryPoint v0.7
|
||||
ENTRY_POINT_V07 = "0x0000000071727De22E5E9d8BAf0edAc6f37da032"
|
||||
|
||||
|
||||
class CreateWalletRequest(BaseModel):
|
||||
"""Create smart wallet request."""
|
||||
|
||||
owner_address: str
|
||||
chain: str
|
||||
network: str = "mainnet"
|
||||
salt: str | None = None # Optional custom salt
|
||||
|
||||
|
||||
class SmartWalletResponse(BaseModel):
|
||||
"""Smart wallet response."""
|
||||
|
||||
id: uuid.UUID
|
||||
address: str
|
||||
owner_address: str
|
||||
factory_address: str
|
||||
chain: str
|
||||
network: str
|
||||
is_deployed: bool
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class UserOperation(BaseModel):
|
||||
"""ERC-4337 UserOperation."""
|
||||
|
||||
sender: str
|
||||
nonce: str
|
||||
init_code: str = "0x"
|
||||
call_data: str = "0x"
|
||||
call_gas_limit: str = "0x0"
|
||||
verification_gas_limit: str = "0x0"
|
||||
pre_verification_gas: str = "0x0"
|
||||
max_fee_per_gas: str = "0x0"
|
||||
max_priority_fee_per_gas: str = "0x0"
|
||||
paymaster_and_data: str = "0x"
|
||||
signature: str = "0x"
|
||||
|
||||
|
||||
class UserOpEstimate(BaseModel):
|
||||
"""UserOperation gas estimate."""
|
||||
|
||||
call_gas_limit: str
|
||||
verification_gas_limit: str
|
||||
pre_verification_gas: str
|
||||
max_fee_per_gas: str
|
||||
max_priority_fee_per_gas: str
|
||||
|
||||
|
||||
@router.post("/create", response_model=SmartWalletResponse)
|
||||
async def create_smart_wallet(
|
||||
request: CreateWalletRequest,
|
||||
api_key: ApiKeyDep,
|
||||
project: ProjectDep,
|
||||
db: DbDep,
|
||||
) -> SmartWallet:
|
||||
"""
|
||||
Create a counterfactual smart wallet address.
|
||||
|
||||
The wallet is not deployed on-chain until the first transaction.
|
||||
"""
|
||||
if not ChainRegistry.is_supported(request.chain, request.network):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported chain/network: {request.chain}/{request.network}",
|
||||
)
|
||||
|
||||
owner_address = request.owner_address.lower()
|
||||
|
||||
# Generate salt if not provided
|
||||
if request.salt:
|
||||
salt = request.salt
|
||||
else:
|
||||
import secrets
|
||||
salt = "0x" + secrets.token_hex(32)
|
||||
|
||||
# Get factory address for chain
|
||||
factory_address = SIMPLE_ACCOUNT_FACTORY.get(request.chain)
|
||||
if not factory_address:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Smart wallets not supported on {request.chain}",
|
||||
)
|
||||
|
||||
# Calculate counterfactual address
|
||||
# This would normally involve calling getAddress on the factory
|
||||
# For simplicity, we'll compute it deterministically
|
||||
wallet_address = await _compute_wallet_address(
|
||||
request.chain, request.network, factory_address, owner_address, salt
|
||||
)
|
||||
|
||||
# Check if wallet already exists
|
||||
existing = await db.execute(
|
||||
select(SmartWallet).where(SmartWallet.address == wallet_address)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Wallet already exists",
|
||||
)
|
||||
|
||||
wallet = SmartWallet(
|
||||
project_id=project.id,
|
||||
address=wallet_address,
|
||||
owner_address=owner_address,
|
||||
factory_address=factory_address,
|
||||
chain=request.chain,
|
||||
network=request.network,
|
||||
salt=salt,
|
||||
is_deployed=False,
|
||||
)
|
||||
|
||||
db.add(wallet)
|
||||
await db.commit()
|
||||
await db.refresh(wallet)
|
||||
|
||||
return wallet
|
||||
|
||||
|
||||
@router.get("/{address}", response_model=SmartWalletResponse)
|
||||
async def get_smart_wallet(
|
||||
address: str,
|
||||
api_key: ApiKeyDep,
|
||||
project: ProjectDep,
|
||||
db: DbDep,
|
||||
) -> SmartWallet:
|
||||
"""Get smart wallet by address."""
|
||||
address = address.lower()
|
||||
|
||||
result = await db.execute(
|
||||
select(SmartWallet).where(
|
||||
SmartWallet.address == address,
|
||||
SmartWallet.project_id == project.id,
|
||||
)
|
||||
)
|
||||
wallet = result.scalar_one_or_none()
|
||||
|
||||
if not wallet:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Wallet not found",
|
||||
)
|
||||
|
||||
# Check if deployed on-chain
|
||||
async with RPCClient(wallet.chain, wallet.network) as client:
|
||||
code = await client.call("eth_getCode", [wallet.address, "latest"])
|
||||
is_deployed = code and code != "0x"
|
||||
|
||||
if is_deployed and not wallet.is_deployed:
|
||||
wallet.is_deployed = True
|
||||
await db.commit()
|
||||
await db.refresh(wallet)
|
||||
|
||||
return wallet
|
||||
|
||||
|
||||
@router.get("", response_model=list[SmartWalletResponse])
|
||||
async def list_smart_wallets(
|
||||
api_key: ApiKeyDep,
|
||||
project: ProjectDep,
|
||||
db: DbDep,
|
||||
chain: str | None = None,
|
||||
) -> list[SmartWallet]:
|
||||
"""List all smart wallets for the project."""
|
||||
query = select(SmartWallet).where(SmartWallet.project_id == project.id)
|
||||
|
||||
if chain:
|
||||
query = query.where(SmartWallet.chain == chain)
|
||||
|
||||
result = await db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("/{address}/estimate", response_model=UserOpEstimate)
|
||||
async def estimate_user_operation(
|
||||
address: str,
|
||||
user_op: UserOperation,
|
||||
api_key: ApiKeyDep,
|
||||
project: ProjectDep,
|
||||
db: DbDep,
|
||||
) -> UserOpEstimate:
|
||||
"""
|
||||
Estimate gas for a UserOperation.
|
||||
|
||||
Returns recommended gas limits and fees.
|
||||
"""
|
||||
address = address.lower()
|
||||
|
||||
# Get wallet
|
||||
result = await db.execute(
|
||||
select(SmartWallet).where(
|
||||
SmartWallet.address == address,
|
||||
SmartWallet.project_id == project.id,
|
||||
)
|
||||
)
|
||||
wallet = result.scalar_one_or_none()
|
||||
|
||||
if not wallet:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Wallet not found",
|
||||
)
|
||||
|
||||
async with RPCClient(wallet.chain, wallet.network) as client:
|
||||
# Get current gas prices
|
||||
gas_price = await client.get_gas_price()
|
||||
|
||||
try:
|
||||
# Try to get EIP-1559 fees
|
||||
max_priority_fee = await client.call("eth_maxPriorityFeePerGas", [])
|
||||
max_priority_fee_int = int(max_priority_fee, 16)
|
||||
|
||||
# Get latest block for baseFee
|
||||
block = await client.call("eth_getBlockByNumber", ["latest", False])
|
||||
base_fee = int(block.get("baseFeePerGas", "0x0"), 16)
|
||||
|
||||
max_fee = base_fee + max_priority_fee_int
|
||||
except Exception:
|
||||
max_priority_fee_int = gas_price // 10
|
||||
max_fee = gas_price
|
||||
|
||||
# Estimate verification and call gas
|
||||
# These are rough estimates - real implementation would simulate
|
||||
verification_gas = 100000
|
||||
call_gas = 50000
|
||||
pre_verification_gas = 21000
|
||||
|
||||
return UserOpEstimate(
|
||||
call_gas_limit=hex(call_gas),
|
||||
verification_gas_limit=hex(verification_gas),
|
||||
pre_verification_gas=hex(pre_verification_gas),
|
||||
max_fee_per_gas=hex(max_fee),
|
||||
max_priority_fee_per_gas=hex(max_priority_fee_int),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{address}/nonce")
|
||||
async def get_wallet_nonce(
|
||||
address: str,
|
||||
api_key: ApiKeyDep,
|
||||
project: ProjectDep,
|
||||
db: DbDep,
|
||||
key: str = "0x0", # Nonce key for multi-dimensional nonces
|
||||
) -> dict:
|
||||
"""Get the current nonce for a smart wallet."""
|
||||
address = address.lower()
|
||||
|
||||
# Get wallet
|
||||
result = await db.execute(
|
||||
select(SmartWallet).where(
|
||||
SmartWallet.address == address,
|
||||
SmartWallet.project_id == project.id,
|
||||
)
|
||||
)
|
||||
wallet = result.scalar_one_or_none()
|
||||
|
||||
if not wallet:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Wallet not found",
|
||||
)
|
||||
|
||||
async with RPCClient(wallet.chain, wallet.network) as client:
|
||||
# Call getNonce on EntryPoint
|
||||
# getNonce(address sender, uint192 key) returns (uint256)
|
||||
key_padded = key[2:].zfill(48) if key.startswith("0x") else key.zfill(48)
|
||||
sender_padded = address[2:].zfill(64)
|
||||
data = f"0x35567e1a{sender_padded}{key_padded.zfill(64)}"
|
||||
|
||||
try:
|
||||
nonce_result = await client.call_contract(ENTRY_POINT_V06, data)
|
||||
nonce = int(nonce_result, 16) if nonce_result != "0x" else 0
|
||||
except Exception:
|
||||
nonce = 0
|
||||
|
||||
return {
|
||||
"address": address,
|
||||
"key": key,
|
||||
"nonce": nonce,
|
||||
"nonce_hex": hex(nonce),
|
||||
}
|
||||
|
||||
|
||||
async def _compute_wallet_address(
|
||||
chain: str,
|
||||
network: str,
|
||||
factory_address: str,
|
||||
owner_address: str,
|
||||
salt: str,
|
||||
) -> str:
|
||||
"""
|
||||
Compute the counterfactual wallet address.
|
||||
|
||||
This calls getAddress on the factory contract.
|
||||
"""
|
||||
async with RPCClient(chain, network) as client:
|
||||
# getAddress(address owner, uint256 salt) returns (address)
|
||||
owner_padded = owner_address[2:].zfill(64)
|
||||
salt_padded = salt[2:].zfill(64) if salt.startswith("0x") else salt.zfill(64)
|
||||
data = f"0x8cb84e18{owner_padded}{salt_padded}"
|
||||
|
||||
try:
|
||||
result = await client.call_contract(factory_address, data)
|
||||
if result and result != "0x":
|
||||
return "0x" + result[-40:]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback: compute CREATE2 address manually
|
||||
import hashlib
|
||||
from eth_account._utils.structured_data.hashing import hash_domain
|
||||
|
||||
# This is a simplified version - real implementation needs init code hash
|
||||
combined = f"{factory_address}{owner_address}{salt}"
|
||||
address_hash = hashlib.sha256(combined.encode()).hexdigest()[-40:]
|
||||
return f"0x{address_hash}"
|
||||
@@ -0,0 +1,364 @@
|
||||
"""Webhooks API - Event-driven notifications."""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from pydantic import BaseModel, HttpUrl
|
||||
from sqlalchemy import select
|
||||
|
||||
from bootnode.api.deps import ApiKeyDep, DbDep, ProjectDep
|
||||
from bootnode.db.models import Webhook, WebhookDelivery
|
||||
from bootnode.core.chains import ChainRegistry
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class WebhookEventType:
|
||||
"""Supported webhook event types."""
|
||||
|
||||
ADDRESS_ACTIVITY = "ADDRESS_ACTIVITY"
|
||||
MINED_TRANSACTION = "MINED_TRANSACTION"
|
||||
NFT_ACTIVITY = "NFT_ACTIVITY"
|
||||
TOKEN_TRANSFER = "TOKEN_TRANSFER"
|
||||
INTERNAL_TRANSFER = "INTERNAL_TRANSFER"
|
||||
NEW_BLOCK = "NEW_BLOCK"
|
||||
PENDING_TRANSACTION = "PENDING_TRANSACTION"
|
||||
|
||||
|
||||
VALID_EVENT_TYPES = {
|
||||
WebhookEventType.ADDRESS_ACTIVITY,
|
||||
WebhookEventType.MINED_TRANSACTION,
|
||||
WebhookEventType.NFT_ACTIVITY,
|
||||
WebhookEventType.TOKEN_TRANSFER,
|
||||
WebhookEventType.INTERNAL_TRANSFER,
|
||||
WebhookEventType.NEW_BLOCK,
|
||||
WebhookEventType.PENDING_TRANSACTION,
|
||||
}
|
||||
|
||||
|
||||
class CreateWebhookRequest(BaseModel):
|
||||
"""Create webhook request."""
|
||||
|
||||
name: str
|
||||
url: HttpUrl
|
||||
chain: str
|
||||
network: str = "mainnet"
|
||||
event_type: str
|
||||
filters: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class WebhookResponse(BaseModel):
|
||||
"""Webhook response."""
|
||||
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
url: str
|
||||
chain: str
|
||||
network: str
|
||||
event_type: str
|
||||
filters: dict[str, Any] | None
|
||||
is_active: bool
|
||||
failure_count: int
|
||||
last_triggered_at: datetime | None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class WebhookDeliveryResponse(BaseModel):
|
||||
"""Webhook delivery response."""
|
||||
|
||||
id: uuid.UUID
|
||||
payload: dict[str, Any]
|
||||
status_code: int | None
|
||||
success: bool
|
||||
attempt_count: int
|
||||
error: str | None
|
||||
delivered_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class WebhookTestResponse(BaseModel):
|
||||
"""Webhook test response."""
|
||||
|
||||
success: bool
|
||||
status_code: int | None
|
||||
response_time_ms: int
|
||||
error: str | None
|
||||
|
||||
|
||||
@router.post("", response_model=WebhookResponse)
|
||||
async def create_webhook(
|
||||
request: CreateWebhookRequest,
|
||||
api_key: ApiKeyDep,
|
||||
project: ProjectDep,
|
||||
db: DbDep,
|
||||
) -> Webhook:
|
||||
"""Create a new webhook subscription."""
|
||||
# Validate event type
|
||||
if request.event_type not in VALID_EVENT_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid event type. Must be one of: {', '.join(VALID_EVENT_TYPES)}",
|
||||
)
|
||||
|
||||
# Validate chain/network
|
||||
if not ChainRegistry.is_supported(request.chain, request.network):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported chain/network: {request.chain}/{request.network}",
|
||||
)
|
||||
|
||||
# Generate webhook secret for HMAC signing
|
||||
webhook_secret = secrets.token_urlsafe(32)
|
||||
|
||||
webhook = Webhook(
|
||||
project_id=project.id,
|
||||
name=request.name,
|
||||
url=str(request.url),
|
||||
chain=request.chain,
|
||||
network=request.network,
|
||||
event_type=request.event_type,
|
||||
filters=request.filters,
|
||||
secret=webhook_secret,
|
||||
)
|
||||
|
||||
db.add(webhook)
|
||||
await db.commit()
|
||||
await db.refresh(webhook)
|
||||
|
||||
return webhook
|
||||
|
||||
|
||||
@router.get("", response_model=list[WebhookResponse])
|
||||
async def list_webhooks(
|
||||
api_key: ApiKeyDep,
|
||||
project: ProjectDep,
|
||||
db: DbDep,
|
||||
chain: str | None = None,
|
||||
event_type: str | None = None,
|
||||
) -> list[Webhook]:
|
||||
"""List all webhooks for the project."""
|
||||
query = select(Webhook).where(Webhook.project_id == project.id)
|
||||
|
||||
if chain:
|
||||
query = query.where(Webhook.chain == chain)
|
||||
if event_type:
|
||||
query = query.where(Webhook.event_type == event_type)
|
||||
|
||||
result = await db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.get("/{webhook_id}", response_model=WebhookResponse)
|
||||
async def get_webhook(
|
||||
webhook_id: uuid.UUID,
|
||||
api_key: ApiKeyDep,
|
||||
project: ProjectDep,
|
||||
db: DbDep,
|
||||
) -> Webhook:
|
||||
"""Get a specific webhook."""
|
||||
result = await db.execute(
|
||||
select(Webhook).where(
|
||||
Webhook.id == webhook_id,
|
||||
Webhook.project_id == project.id,
|
||||
)
|
||||
)
|
||||
webhook = result.scalar_one_or_none()
|
||||
|
||||
if not webhook:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Webhook not found",
|
||||
)
|
||||
|
||||
return webhook
|
||||
|
||||
|
||||
@router.patch("/{webhook_id}", response_model=WebhookResponse)
|
||||
async def update_webhook(
|
||||
webhook_id: uuid.UUID,
|
||||
api_key: ApiKeyDep,
|
||||
project: ProjectDep,
|
||||
db: DbDep,
|
||||
name: str | None = None,
|
||||
url: HttpUrl | None = None,
|
||||
is_active: bool | None = None,
|
||||
filters: dict[str, Any] | None = None,
|
||||
) -> Webhook:
|
||||
"""Update a webhook."""
|
||||
result = await db.execute(
|
||||
select(Webhook).where(
|
||||
Webhook.id == webhook_id,
|
||||
Webhook.project_id == project.id,
|
||||
)
|
||||
)
|
||||
webhook = result.scalar_one_or_none()
|
||||
|
||||
if not webhook:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Webhook not found",
|
||||
)
|
||||
|
||||
if name is not None:
|
||||
webhook.name = name
|
||||
if url is not None:
|
||||
webhook.url = str(url)
|
||||
if is_active is not None:
|
||||
webhook.is_active = is_active
|
||||
if filters is not None:
|
||||
webhook.filters = filters
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(webhook)
|
||||
|
||||
return webhook
|
||||
|
||||
|
||||
@router.delete("/{webhook_id}")
|
||||
async def delete_webhook(
|
||||
webhook_id: uuid.UUID,
|
||||
api_key: ApiKeyDep,
|
||||
project: ProjectDep,
|
||||
db: DbDep,
|
||||
) -> dict:
|
||||
"""Delete a webhook."""
|
||||
result = await db.execute(
|
||||
select(Webhook).where(
|
||||
Webhook.id == webhook_id,
|
||||
Webhook.project_id == project.id,
|
||||
)
|
||||
)
|
||||
webhook = result.scalar_one_or_none()
|
||||
|
||||
if not webhook:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Webhook not found",
|
||||
)
|
||||
|
||||
await db.delete(webhook)
|
||||
await db.commit()
|
||||
|
||||
return {"status": "deleted", "id": str(webhook_id)}
|
||||
|
||||
|
||||
@router.get("/{webhook_id}/deliveries", response_model=list[WebhookDeliveryResponse])
|
||||
async def get_webhook_deliveries(
|
||||
webhook_id: uuid.UUID,
|
||||
api_key: ApiKeyDep,
|
||||
project: ProjectDep,
|
||||
db: DbDep,
|
||||
limit: int = 50,
|
||||
) -> list[WebhookDelivery]:
|
||||
"""Get recent deliveries for a webhook."""
|
||||
# Verify webhook exists and belongs to project
|
||||
result = await db.execute(
|
||||
select(Webhook).where(
|
||||
Webhook.id == webhook_id,
|
||||
Webhook.project_id == project.id,
|
||||
)
|
||||
)
|
||||
webhook = result.scalar_one_or_none()
|
||||
|
||||
if not webhook:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Webhook not found",
|
||||
)
|
||||
|
||||
# Get recent deliveries
|
||||
result = await db.execute(
|
||||
select(WebhookDelivery)
|
||||
.where(WebhookDelivery.webhook_id == webhook_id)
|
||||
.order_by(WebhookDelivery.delivered_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("/{webhook_id}/test", response_model=WebhookTestResponse)
|
||||
async def test_webhook(
|
||||
webhook_id: uuid.UUID,
|
||||
api_key: ApiKeyDep,
|
||||
project: ProjectDep,
|
||||
db: DbDep,
|
||||
) -> WebhookTestResponse:
|
||||
"""Send a test event to a webhook."""
|
||||
import httpx
|
||||
import time
|
||||
|
||||
result = await db.execute(
|
||||
select(Webhook).where(
|
||||
Webhook.id == webhook_id,
|
||||
Webhook.project_id == project.id,
|
||||
)
|
||||
)
|
||||
webhook = result.scalar_one_or_none()
|
||||
|
||||
if not webhook:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Webhook not found",
|
||||
)
|
||||
|
||||
# Create test payload
|
||||
test_payload = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"event": webhook.event_type,
|
||||
"chain": webhook.chain,
|
||||
"network": webhook.network,
|
||||
"data": {
|
||||
"test": True,
|
||||
"message": "This is a test webhook delivery",
|
||||
},
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
# Sign payload
|
||||
signature = hmac.new(
|
||||
webhook.secret.encode(),
|
||||
str(test_payload).encode(),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
start_time = time.time()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(
|
||||
webhook.url,
|
||||
json=test_payload,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Bootnode-Signature": signature,
|
||||
"X-Bootnode-Event": webhook.event_type,
|
||||
},
|
||||
)
|
||||
|
||||
response_time = int((time.time() - start_time) * 1000)
|
||||
|
||||
return WebhookTestResponse(
|
||||
success=response.status_code < 400,
|
||||
status_code=response.status_code,
|
||||
response_time_ms=response_time,
|
||||
error=None if response.status_code < 400 else response.text[:500],
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
response_time = int((time.time() - start_time) * 1000)
|
||||
return WebhookTestResponse(
|
||||
success=False,
|
||||
status_code=None,
|
||||
response_time_ms=response_time,
|
||||
error=str(e),
|
||||
)
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Application configuration."""
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field, PostgresDsn, RedisDsn
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings loaded from environment variables."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# Application
|
||||
app_name: str = "Bootnode"
|
||||
app_env: Literal["development", "staging", "production"] = "development"
|
||||
debug: bool = False
|
||||
api_prefix: str = "/v1"
|
||||
|
||||
# Database
|
||||
database_url: PostgresDsn = Field(
|
||||
default="postgresql+asyncpg://bootnode:bootnode@localhost:5432/bootnode"
|
||||
)
|
||||
db_pool_size: int = 20
|
||||
db_max_overflow: int = 10
|
||||
|
||||
# Redis
|
||||
redis_url: RedisDsn = Field(default="redis://localhost:6379/0")
|
||||
|
||||
# Authentication
|
||||
jwt_secret: str = Field(default="change-me-in-production")
|
||||
jwt_algorithm: str = "HS256"
|
||||
jwt_expire_minutes: int = 60 * 24 # 24 hours
|
||||
api_key_salt: str = Field(default="change-me-in-production")
|
||||
|
||||
# Hanzo IAM Integration (hanzo.id, zoo.id, lux.id, pars.id)
|
||||
iam_url: str = "https://iam.hanzo.ai"
|
||||
iam_client_id: str = ""
|
||||
iam_client_secret: str = ""
|
||||
enable_multi_tenant: bool = True
|
||||
allowed_orgs: list[str] = ["hanzo", "zoo", "lux", "pars"]
|
||||
|
||||
# Rate Limiting
|
||||
rate_limit_requests: int = 100 # per minute
|
||||
rate_limit_compute_units: int = 1000 # per minute
|
||||
|
||||
# Chain RPCs (upstream nodes)
|
||||
eth_mainnet_rpc: str = ""
|
||||
eth_sepolia_rpc: str = ""
|
||||
eth_holesky_rpc: str = ""
|
||||
polygon_mainnet_rpc: str = ""
|
||||
polygon_amoy_rpc: str = ""
|
||||
arbitrum_mainnet_rpc: str = ""
|
||||
arbitrum_sepolia_rpc: str = ""
|
||||
optimism_mainnet_rpc: str = ""
|
||||
optimism_sepolia_rpc: str = ""
|
||||
base_mainnet_rpc: str = ""
|
||||
base_sepolia_rpc: str = ""
|
||||
avalanche_mainnet_rpc: str = ""
|
||||
avalanche_fuji_rpc: str = ""
|
||||
bsc_mainnet_rpc: str = ""
|
||||
bsc_testnet_rpc: str = ""
|
||||
lux_mainnet_rpc: str = ""
|
||||
lux_testnet_rpc: str = ""
|
||||
solana_mainnet_rpc: str = ""
|
||||
solana_devnet_rpc: str = ""
|
||||
|
||||
# DataStore (ClickHouse)
|
||||
datastore_url: str = "clickhouse://bootnode:bootnode@localhost:8123/bootnode"
|
||||
datastore_pool_size: int = 10
|
||||
|
||||
# Message Queue (Redis-based, @hanzo/mq compatible)
|
||||
# Uses the same Redis instance as cache - different db index for isolation
|
||||
mq_redis_db: int = 1 # Redis DB index for MQ (separate from cache at db 0)
|
||||
|
||||
# External Services
|
||||
ipfs_gateway: str = "https://ipfs.io/ipfs/"
|
||||
|
||||
# Webhooks
|
||||
webhook_timeout: int = 30
|
||||
webhook_max_retries: int = 5
|
||||
|
||||
# ERC-4337 Bundler
|
||||
bundler_private_key: str = ""
|
||||
bundler_beneficiary: str = ""
|
||||
|
||||
@property
|
||||
def is_production(self) -> bool:
|
||||
return self.app_env == "production"
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
"""Get cached settings instance."""
|
||||
return Settings()
|
||||
@@ -0,0 +1 @@
|
||||
"""Core business logic."""
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
"""Caching layer."""
|
||||
|
||||
from bootnode.core.cache.redis import RedisClient, redis_client
|
||||
|
||||
__all__ = ["RedisClient", "redis_client"]
|
||||
Vendored
+167
@@ -0,0 +1,167 @@
|
||||
"""Redis cache client."""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import redis.asyncio as redis
|
||||
import structlog
|
||||
|
||||
from bootnode.config import get_settings
|
||||
|
||||
logger = structlog.get_logger()
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class RedisClient:
|
||||
"""Async Redis client wrapper."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._client: redis.Redis | None = None
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Initialize Redis connection."""
|
||||
self._client = redis.from_url(
|
||||
str(settings.redis_url),
|
||||
encoding="utf-8",
|
||||
decode_responses=True,
|
||||
)
|
||||
logger.info("Redis connected", url=str(settings.redis_url).split("@")[-1])
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close Redis connection."""
|
||||
if self._client:
|
||||
await self._client.aclose()
|
||||
logger.info("Redis connection closed")
|
||||
|
||||
@property
|
||||
def client(self) -> redis.Redis:
|
||||
if not self._client:
|
||||
raise RuntimeError("Redis not initialized")
|
||||
return self._client
|
||||
|
||||
# Key-Value Operations
|
||||
async def get(self, key: str) -> str | None:
|
||||
"""Get a value by key."""
|
||||
return await self.client.get(key)
|
||||
|
||||
async def set(
|
||||
self,
|
||||
key: str,
|
||||
value: str,
|
||||
ex: int | None = None,
|
||||
px: int | None = None,
|
||||
) -> bool:
|
||||
"""Set a value with optional expiration."""
|
||||
return await self.client.set(key, value, ex=ex, px=px) or False
|
||||
|
||||
async def delete(self, *keys: str) -> int:
|
||||
"""Delete one or more keys."""
|
||||
return await self.client.delete(*keys)
|
||||
|
||||
async def exists(self, *keys: str) -> int:
|
||||
"""Check if keys exist."""
|
||||
return await self.client.exists(*keys)
|
||||
|
||||
async def expire(self, key: str, seconds: int) -> bool:
|
||||
"""Set expiration on a key."""
|
||||
return await self.client.expire(key, seconds)
|
||||
|
||||
async def ttl(self, key: str) -> int:
|
||||
"""Get TTL of a key."""
|
||||
return await self.client.ttl(key)
|
||||
|
||||
# JSON Operations
|
||||
async def get_json(self, key: str) -> Any:
|
||||
"""Get and deserialize JSON value."""
|
||||
value = await self.get(key)
|
||||
if value:
|
||||
return json.loads(value)
|
||||
return None
|
||||
|
||||
async def set_json(
|
||||
self,
|
||||
key: str,
|
||||
value: Any,
|
||||
ex: int | None = None,
|
||||
) -> bool:
|
||||
"""Serialize and set JSON value."""
|
||||
return await self.set(key, json.dumps(value), ex=ex)
|
||||
|
||||
# Rate Limiting
|
||||
async def rate_limit_check(
|
||||
self,
|
||||
key: str,
|
||||
limit: int,
|
||||
window_seconds: int = 60,
|
||||
) -> tuple[bool, int]:
|
||||
"""
|
||||
Check rate limit using sliding window.
|
||||
Returns (allowed, remaining).
|
||||
"""
|
||||
pipe = self.client.pipeline()
|
||||
pipe.incr(key)
|
||||
pipe.expire(key, window_seconds)
|
||||
results = await pipe.execute()
|
||||
|
||||
current = results[0]
|
||||
remaining = max(0, limit - current)
|
||||
allowed = current <= limit
|
||||
|
||||
return allowed, remaining
|
||||
|
||||
async def rate_limit_get(self, key: str, limit: int) -> tuple[int, int]:
|
||||
"""Get current rate limit status. Returns (current, remaining)."""
|
||||
current = await self.get(key)
|
||||
current_int = int(current) if current else 0
|
||||
remaining = max(0, limit - current_int)
|
||||
return current_int, remaining
|
||||
|
||||
# RPC Response Caching
|
||||
def rpc_cache_key(
|
||||
self,
|
||||
chain: str,
|
||||
network: str,
|
||||
method: str,
|
||||
params_hash: str,
|
||||
) -> str:
|
||||
"""Generate cache key for RPC response."""
|
||||
return f"rpc:{chain}:{network}:{method}:{params_hash}"
|
||||
|
||||
async def get_rpc_response(
|
||||
self,
|
||||
chain: str,
|
||||
network: str,
|
||||
method: str,
|
||||
params_hash: str,
|
||||
) -> Any:
|
||||
"""Get cached RPC response."""
|
||||
key = self.rpc_cache_key(chain, network, method, params_hash)
|
||||
return await self.get_json(key)
|
||||
|
||||
async def set_rpc_response(
|
||||
self,
|
||||
chain: str,
|
||||
network: str,
|
||||
method: str,
|
||||
params_hash: str,
|
||||
response: Any,
|
||||
ttl: int = 12, # ~1 block on most chains
|
||||
) -> None:
|
||||
"""Cache RPC response."""
|
||||
key = self.rpc_cache_key(chain, network, method, params_hash)
|
||||
await self.set_json(key, response, ex=ttl)
|
||||
|
||||
# Pub/Sub for Webhooks
|
||||
async def publish(self, channel: str, message: str) -> int:
|
||||
"""Publish message to channel."""
|
||||
return await self.client.publish(channel, message)
|
||||
|
||||
async def subscribe(self, *channels: str) -> redis.client.PubSub:
|
||||
"""Subscribe to channels."""
|
||||
pubsub = self.client.pubsub()
|
||||
await pubsub.subscribe(*channels)
|
||||
return pubsub
|
||||
|
||||
|
||||
# Global singleton
|
||||
redis_client = RedisClient()
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Chain configurations and utilities."""
|
||||
|
||||
from bootnode.core.chains.registry import ChainRegistry, Chain, Network
|
||||
from bootnode.core.chains.rpc import RPCClient
|
||||
|
||||
__all__ = ["ChainRegistry", "Chain", "Network", "RPCClient"]
|
||||
@@ -0,0 +1,315 @@
|
||||
"""Chain registry - supported blockchains and networks."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import ClassVar
|
||||
|
||||
from bootnode.config import get_settings
|
||||
|
||||
|
||||
class ChainType(str, Enum):
|
||||
"""Blockchain type."""
|
||||
|
||||
EVM = "evm"
|
||||
SOLANA = "solana"
|
||||
BITCOIN = "bitcoin"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Network:
|
||||
"""Network configuration."""
|
||||
|
||||
name: str
|
||||
chain_id: int | None # None for non-EVM
|
||||
is_testnet: bool
|
||||
rpc_env_var: str
|
||||
explorer_url: str | None = None
|
||||
native_currency: str = "ETH"
|
||||
native_decimals: int = 18
|
||||
|
||||
|
||||
@dataclass
|
||||
class Chain:
|
||||
"""Blockchain configuration."""
|
||||
|
||||
name: str
|
||||
slug: str
|
||||
chain_type: ChainType
|
||||
networks: dict[str, Network] = field(default_factory=dict)
|
||||
logo_url: str | None = None
|
||||
|
||||
def get_rpc_url(self, network: str = "mainnet") -> str | None:
|
||||
"""Get RPC URL for a network from environment."""
|
||||
settings = get_settings()
|
||||
net = self.networks.get(network)
|
||||
if not net:
|
||||
return None
|
||||
return getattr(settings, net.rpc_env_var, None) or None
|
||||
|
||||
|
||||
class ChainRegistry:
|
||||
"""Registry of supported blockchains."""
|
||||
|
||||
_chains: ClassVar[dict[str, Chain]] = {}
|
||||
_initialized: ClassVar[bool] = False
|
||||
|
||||
@classmethod
|
||||
def initialize(cls) -> None:
|
||||
"""Initialize the chain registry with supported chains."""
|
||||
if cls._initialized:
|
||||
return
|
||||
|
||||
# Ethereum
|
||||
cls._chains["ethereum"] = Chain(
|
||||
name="Ethereum",
|
||||
slug="ethereum",
|
||||
chain_type=ChainType.EVM,
|
||||
networks={
|
||||
"mainnet": Network(
|
||||
name="Mainnet",
|
||||
chain_id=1,
|
||||
is_testnet=False,
|
||||
rpc_env_var="eth_mainnet_rpc",
|
||||
explorer_url="https://etherscan.io",
|
||||
),
|
||||
"sepolia": Network(
|
||||
name="Sepolia",
|
||||
chain_id=11155111,
|
||||
is_testnet=True,
|
||||
rpc_env_var="eth_sepolia_rpc",
|
||||
explorer_url="https://sepolia.etherscan.io",
|
||||
),
|
||||
"holesky": Network(
|
||||
name="Holesky",
|
||||
chain_id=17000,
|
||||
is_testnet=True,
|
||||
rpc_env_var="eth_holesky_rpc",
|
||||
explorer_url="https://holesky.etherscan.io",
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
# Polygon
|
||||
cls._chains["polygon"] = Chain(
|
||||
name="Polygon",
|
||||
slug="polygon",
|
||||
chain_type=ChainType.EVM,
|
||||
networks={
|
||||
"mainnet": Network(
|
||||
name="Mainnet",
|
||||
chain_id=137,
|
||||
is_testnet=False,
|
||||
rpc_env_var="polygon_mainnet_rpc",
|
||||
explorer_url="https://polygonscan.com",
|
||||
native_currency="MATIC",
|
||||
),
|
||||
"amoy": Network(
|
||||
name="Amoy",
|
||||
chain_id=80002,
|
||||
is_testnet=True,
|
||||
rpc_env_var="polygon_amoy_rpc",
|
||||
explorer_url="https://amoy.polygonscan.com",
|
||||
native_currency="MATIC",
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
# Arbitrum
|
||||
cls._chains["arbitrum"] = Chain(
|
||||
name="Arbitrum",
|
||||
slug="arbitrum",
|
||||
chain_type=ChainType.EVM,
|
||||
networks={
|
||||
"mainnet": Network(
|
||||
name="One",
|
||||
chain_id=42161,
|
||||
is_testnet=False,
|
||||
rpc_env_var="arbitrum_mainnet_rpc",
|
||||
explorer_url="https://arbiscan.io",
|
||||
),
|
||||
"sepolia": Network(
|
||||
name="Sepolia",
|
||||
chain_id=421614,
|
||||
is_testnet=True,
|
||||
rpc_env_var="arbitrum_sepolia_rpc",
|
||||
explorer_url="https://sepolia.arbiscan.io",
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
# Optimism
|
||||
cls._chains["optimism"] = Chain(
|
||||
name="Optimism",
|
||||
slug="optimism",
|
||||
chain_type=ChainType.EVM,
|
||||
networks={
|
||||
"mainnet": Network(
|
||||
name="Mainnet",
|
||||
chain_id=10,
|
||||
is_testnet=False,
|
||||
rpc_env_var="optimism_mainnet_rpc",
|
||||
explorer_url="https://optimistic.etherscan.io",
|
||||
),
|
||||
"sepolia": Network(
|
||||
name="Sepolia",
|
||||
chain_id=11155420,
|
||||
is_testnet=True,
|
||||
rpc_env_var="optimism_sepolia_rpc",
|
||||
explorer_url="https://sepolia-optimism.etherscan.io",
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
# Base
|
||||
cls._chains["base"] = Chain(
|
||||
name="Base",
|
||||
slug="base",
|
||||
chain_type=ChainType.EVM,
|
||||
networks={
|
||||
"mainnet": Network(
|
||||
name="Mainnet",
|
||||
chain_id=8453,
|
||||
is_testnet=False,
|
||||
rpc_env_var="base_mainnet_rpc",
|
||||
explorer_url="https://basescan.org",
|
||||
),
|
||||
"sepolia": Network(
|
||||
name="Sepolia",
|
||||
chain_id=84532,
|
||||
is_testnet=True,
|
||||
rpc_env_var="base_sepolia_rpc",
|
||||
explorer_url="https://sepolia.basescan.org",
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
# Avalanche
|
||||
cls._chains["avalanche"] = Chain(
|
||||
name="Avalanche",
|
||||
slug="avalanche",
|
||||
chain_type=ChainType.EVM,
|
||||
networks={
|
||||
"mainnet": Network(
|
||||
name="C-Chain",
|
||||
chain_id=43114,
|
||||
is_testnet=False,
|
||||
rpc_env_var="avalanche_mainnet_rpc",
|
||||
explorer_url="https://snowtrace.io",
|
||||
native_currency="AVAX",
|
||||
),
|
||||
"fuji": Network(
|
||||
name="Fuji",
|
||||
chain_id=43113,
|
||||
is_testnet=True,
|
||||
rpc_env_var="avalanche_fuji_rpc",
|
||||
explorer_url="https://testnet.snowtrace.io",
|
||||
native_currency="AVAX",
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
# BNB Chain
|
||||
cls._chains["bsc"] = Chain(
|
||||
name="BNB Smart Chain",
|
||||
slug="bsc",
|
||||
chain_type=ChainType.EVM,
|
||||
networks={
|
||||
"mainnet": Network(
|
||||
name="Mainnet",
|
||||
chain_id=56,
|
||||
is_testnet=False,
|
||||
rpc_env_var="bsc_mainnet_rpc",
|
||||
explorer_url="https://bscscan.com",
|
||||
native_currency="BNB",
|
||||
),
|
||||
"testnet": Network(
|
||||
name="Testnet",
|
||||
chain_id=97,
|
||||
is_testnet=True,
|
||||
rpc_env_var="bsc_testnet_rpc",
|
||||
explorer_url="https://testnet.bscscan.com",
|
||||
native_currency="BNB",
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
# Lux
|
||||
cls._chains["lux"] = Chain(
|
||||
name="Lux",
|
||||
slug="lux",
|
||||
chain_type=ChainType.EVM,
|
||||
networks={
|
||||
"mainnet": Network(
|
||||
name="Mainnet",
|
||||
chain_id=96369,
|
||||
is_testnet=False,
|
||||
rpc_env_var="lux_mainnet_rpc",
|
||||
explorer_url="https://explore.lux.network",
|
||||
native_currency="LUX",
|
||||
),
|
||||
"testnet": Network(
|
||||
name="Testnet",
|
||||
chain_id=96370,
|
||||
is_testnet=True,
|
||||
rpc_env_var="lux_testnet_rpc",
|
||||
explorer_url="https://explore.testnet.lux.network",
|
||||
native_currency="LUX",
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
# Solana
|
||||
cls._chains["solana"] = Chain(
|
||||
name="Solana",
|
||||
slug="solana",
|
||||
chain_type=ChainType.SOLANA,
|
||||
networks={
|
||||
"mainnet": Network(
|
||||
name="Mainnet Beta",
|
||||
chain_id=None,
|
||||
is_testnet=False,
|
||||
rpc_env_var="solana_mainnet_rpc",
|
||||
explorer_url="https://solscan.io",
|
||||
native_currency="SOL",
|
||||
native_decimals=9,
|
||||
),
|
||||
"devnet": Network(
|
||||
name="Devnet",
|
||||
chain_id=None,
|
||||
is_testnet=True,
|
||||
rpc_env_var="solana_devnet_rpc",
|
||||
explorer_url="https://solscan.io?cluster=devnet",
|
||||
native_currency="SOL",
|
||||
native_decimals=9,
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
cls._initialized = True
|
||||
|
||||
@classmethod
|
||||
def get_chain(cls, slug: str) -> Chain | None:
|
||||
"""Get chain by slug."""
|
||||
cls.initialize()
|
||||
return cls._chains.get(slug)
|
||||
|
||||
@classmethod
|
||||
def get_all_chains(cls) -> dict[str, Chain]:
|
||||
"""Get all supported chains."""
|
||||
cls.initialize()
|
||||
return cls._chains.copy()
|
||||
|
||||
@classmethod
|
||||
def get_evm_chains(cls) -> dict[str, Chain]:
|
||||
"""Get all EVM-compatible chains."""
|
||||
cls.initialize()
|
||||
return {k: v for k, v in cls._chains.items() if v.chain_type == ChainType.EVM}
|
||||
|
||||
@classmethod
|
||||
def is_supported(cls, chain: str, network: str = "mainnet") -> bool:
|
||||
"""Check if chain/network combination is supported."""
|
||||
cls.initialize()
|
||||
chain_config = cls._chains.get(chain)
|
||||
if not chain_config:
|
||||
return False
|
||||
return network in chain_config.networks
|
||||
@@ -0,0 +1,270 @@
|
||||
"""RPC client for blockchain interactions."""
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import structlog
|
||||
from tenacity import retry, stop_after_attempt, wait_exponential
|
||||
|
||||
from bootnode.core.chains.registry import ChainRegistry, ChainType
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
# Compute unit costs for different methods
|
||||
COMPUTE_UNITS: dict[str, int] = {
|
||||
# Free tier (1 CU)
|
||||
"eth_chainId": 1,
|
||||
"eth_blockNumber": 1,
|
||||
"eth_syncing": 1,
|
||||
"eth_gasPrice": 1,
|
||||
"eth_maxPriorityFeePerGas": 1,
|
||||
"net_version": 1,
|
||||
"net_listening": 1,
|
||||
"web3_clientVersion": 1,
|
||||
# Light (5 CU)
|
||||
"eth_getBalance": 5,
|
||||
"eth_getCode": 5,
|
||||
"eth_getStorageAt": 5,
|
||||
"eth_getTransactionCount": 5,
|
||||
"eth_getBlockByNumber": 5,
|
||||
"eth_getBlockByHash": 5,
|
||||
"eth_getTransactionByHash": 5,
|
||||
"eth_getTransactionReceipt": 5,
|
||||
# Medium (10 CU)
|
||||
"eth_call": 10,
|
||||
"eth_estimateGas": 10,
|
||||
"eth_feeHistory": 10,
|
||||
"eth_getBlockReceipts": 10,
|
||||
# Heavy (25 CU)
|
||||
"eth_getLogs": 25,
|
||||
"eth_newFilter": 25,
|
||||
"eth_newBlockFilter": 25,
|
||||
"eth_newPendingTransactionFilter": 25,
|
||||
"eth_getFilterChanges": 25,
|
||||
"eth_getFilterLogs": 25,
|
||||
# Transaction (50 CU)
|
||||
"eth_sendRawTransaction": 50,
|
||||
# Debug/Trace (100+ CU)
|
||||
"debug_traceTransaction": 100,
|
||||
"debug_traceBlockByNumber": 150,
|
||||
"debug_traceBlockByHash": 150,
|
||||
"trace_block": 100,
|
||||
"trace_transaction": 100,
|
||||
"trace_call": 100,
|
||||
# ERC-4337 Bundler (variable)
|
||||
"eth_sendUserOperation": 75,
|
||||
"eth_estimateUserOperationGas": 25,
|
||||
"eth_getUserOperationByHash": 10,
|
||||
"eth_getUserOperationReceipt": 10,
|
||||
"eth_supportedEntryPoints": 1,
|
||||
}
|
||||
|
||||
|
||||
def get_compute_units(method: str) -> int:
|
||||
"""Get compute units for a method."""
|
||||
return COMPUTE_UNITS.get(method, 10) # Default to 10 CU
|
||||
|
||||
|
||||
class RPCError(Exception):
|
||||
"""RPC error."""
|
||||
|
||||
def __init__(self, code: int, message: str, data: Any = None):
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.data = data
|
||||
super().__init__(f"RPC Error {code}: {message}")
|
||||
|
||||
|
||||
class RPCClient:
|
||||
"""JSON-RPC client for blockchain interactions."""
|
||||
|
||||
def __init__(self, chain: str, network: str = "mainnet"):
|
||||
self.chain_slug = chain
|
||||
self.network = network
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
|
||||
chain_config = ChainRegistry.get_chain(chain)
|
||||
if not chain_config:
|
||||
raise ValueError(f"Unsupported chain: {chain}")
|
||||
|
||||
self.chain = chain_config
|
||||
self.rpc_url = chain_config.get_rpc_url(network)
|
||||
if not self.rpc_url:
|
||||
raise ValueError(f"No RPC URL configured for {chain}/{network}")
|
||||
|
||||
self.is_evm = chain_config.chain_type == ChainType.EVM
|
||||
|
||||
async def __aenter__(self) -> "RPCClient":
|
||||
self._client = httpx.AsyncClient(timeout=30.0)
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args: Any) -> None:
|
||||
if self._client:
|
||||
await self._client.aclose()
|
||||
|
||||
@property
|
||||
def client(self) -> httpx.AsyncClient:
|
||||
if not self._client:
|
||||
raise RuntimeError("Client not initialized. Use async context manager.")
|
||||
return self._client
|
||||
|
||||
@retry(
|
||||
stop=stop_after_attempt(3),
|
||||
wait=wait_exponential(multiplier=0.5, min=0.5, max=5),
|
||||
)
|
||||
async def call(
|
||||
self,
|
||||
method: str,
|
||||
params: list[Any] | None = None,
|
||||
request_id: int | str = 1,
|
||||
) -> Any:
|
||||
"""Make a JSON-RPC call."""
|
||||
payload = {
|
||||
"jsonrpc": "2.0",
|
||||
"method": method,
|
||||
"params": params or [],
|
||||
"id": request_id,
|
||||
}
|
||||
|
||||
logger.debug("RPC call", chain=self.chain_slug, network=self.network, method=method)
|
||||
|
||||
response = await self.client.post(
|
||||
self.rpc_url,
|
||||
json=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
|
||||
if "error" in data:
|
||||
error = data["error"]
|
||||
raise RPCError(
|
||||
code=error.get("code", -32000),
|
||||
message=error.get("message", "Unknown error"),
|
||||
data=error.get("data"),
|
||||
)
|
||||
|
||||
return data.get("result")
|
||||
|
||||
async def batch_call(
|
||||
self,
|
||||
requests: list[tuple[str, list[Any] | None]],
|
||||
) -> list[Any]:
|
||||
"""Make a batch JSON-RPC call."""
|
||||
payload = [
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": method,
|
||||
"params": params or [],
|
||||
"id": i + 1,
|
||||
}
|
||||
for i, (method, params) in enumerate(requests)
|
||||
]
|
||||
|
||||
response = await self.client.post(
|
||||
self.rpc_url,
|
||||
json=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
|
||||
# Sort by id to maintain order
|
||||
data.sort(key=lambda x: x.get("id", 0))
|
||||
|
||||
results = []
|
||||
for item in data:
|
||||
if "error" in item:
|
||||
results.append(item["error"])
|
||||
else:
|
||||
results.append(item.get("result"))
|
||||
|
||||
return results
|
||||
|
||||
# Convenience methods
|
||||
async def get_block_number(self) -> int:
|
||||
"""Get the current block number."""
|
||||
result = await self.call("eth_blockNumber")
|
||||
return int(result, 16)
|
||||
|
||||
async def get_balance(self, address: str, block: str = "latest") -> int:
|
||||
"""Get the balance of an address in wei."""
|
||||
result = await self.call("eth_getBalance", [address, block])
|
||||
return int(result, 16)
|
||||
|
||||
async def get_transaction(self, tx_hash: str) -> dict[str, Any] | None:
|
||||
"""Get transaction by hash."""
|
||||
return await self.call("eth_getTransactionByHash", [tx_hash])
|
||||
|
||||
async def get_transaction_receipt(self, tx_hash: str) -> dict[str, Any] | None:
|
||||
"""Get transaction receipt by hash."""
|
||||
return await self.call("eth_getTransactionReceipt", [tx_hash])
|
||||
|
||||
async def get_logs(
|
||||
self,
|
||||
from_block: int | str = "latest",
|
||||
to_block: int | str = "latest",
|
||||
address: str | list[str] | None = None,
|
||||
topics: list[str | list[str] | None] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Get logs matching filter."""
|
||||
filter_params: dict[str, Any] = {
|
||||
"fromBlock": hex(from_block) if isinstance(from_block, int) else from_block,
|
||||
"toBlock": hex(to_block) if isinstance(to_block, int) else to_block,
|
||||
}
|
||||
if address:
|
||||
filter_params["address"] = address
|
||||
if topics:
|
||||
filter_params["topics"] = topics
|
||||
|
||||
return await self.call("eth_getLogs", [filter_params])
|
||||
|
||||
async def send_raw_transaction(self, signed_tx: str) -> str:
|
||||
"""Send a signed transaction."""
|
||||
return await self.call("eth_sendRawTransaction", [signed_tx])
|
||||
|
||||
async def call_contract(
|
||||
self,
|
||||
to: str,
|
||||
data: str,
|
||||
from_address: str | None = None,
|
||||
block: str = "latest",
|
||||
) -> str:
|
||||
"""Call a contract method."""
|
||||
call_params: dict[str, Any] = {"to": to, "data": data}
|
||||
if from_address:
|
||||
call_params["from"] = from_address
|
||||
return await self.call("eth_call", [call_params, block])
|
||||
|
||||
async def estimate_gas(
|
||||
self,
|
||||
to: str,
|
||||
data: str | None = None,
|
||||
from_address: str | None = None,
|
||||
value: int | None = None,
|
||||
) -> int:
|
||||
"""Estimate gas for a transaction."""
|
||||
tx_params: dict[str, Any] = {"to": to}
|
||||
if data:
|
||||
tx_params["data"] = data
|
||||
if from_address:
|
||||
tx_params["from"] = from_address
|
||||
if value:
|
||||
tx_params["value"] = hex(value)
|
||||
|
||||
result = await self.call("eth_estimateGas", [tx_params])
|
||||
return int(result, 16)
|
||||
|
||||
async def get_gas_price(self) -> int:
|
||||
"""Get current gas price."""
|
||||
result = await self.call("eth_gasPrice")
|
||||
return int(result, 16)
|
||||
|
||||
async def get_chain_id(self) -> int:
|
||||
"""Get chain ID."""
|
||||
result = await self.call("eth_chainId")
|
||||
return int(result, 16)
|
||||
@@ -0,0 +1,24 @@
|
||||
"""DataStore (ClickHouse) client for high-performance analytics."""
|
||||
|
||||
from .client import DataStoreClient, datastore_client
|
||||
from .queries import (
|
||||
get_blocks,
|
||||
get_transactions,
|
||||
get_token_transfers,
|
||||
get_nft_transfers,
|
||||
get_address_activity,
|
||||
get_api_usage,
|
||||
record_api_usage,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DataStoreClient",
|
||||
"datastore_client",
|
||||
"get_blocks",
|
||||
"get_transactions",
|
||||
"get_token_transfers",
|
||||
"get_nft_transfers",
|
||||
"get_address_activity",
|
||||
"get_api_usage",
|
||||
"record_api_usage",
|
||||
]
|
||||
@@ -0,0 +1,128 @@
|
||||
"""DataStore (ClickHouse) async client."""
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import structlog
|
||||
from aiochclient import ChClient
|
||||
from aiohttp import ClientSession
|
||||
|
||||
from bootnode.config import get_settings
|
||||
|
||||
logger = structlog.get_logger()
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class DataStoreClient:
|
||||
"""Async ClickHouse client for high-performance analytics queries."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._client: ChClient | None = None
|
||||
self._session: ClientSession | None = None
|
||||
self._url: str = ""
|
||||
self._database: str = "bootnode"
|
||||
self._initialized = False
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Initialize the ClickHouse connection."""
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
url = settings.datastore_url
|
||||
if not url:
|
||||
logger.warning("DataStore URL not configured, skipping initialization")
|
||||
return
|
||||
|
||||
# Parse connection URL: clickhouse://user:pass@host:port/database
|
||||
parsed = urlparse(url)
|
||||
self._url = f"http://{parsed.hostname}:{parsed.port or 8123}"
|
||||
self._database = parsed.path.lstrip("/") or "bootnode"
|
||||
|
||||
self._session = ClientSession()
|
||||
self._client = ChClient(
|
||||
self._session,
|
||||
url=self._url,
|
||||
database=self._database,
|
||||
user=parsed.username or "default",
|
||||
password=parsed.password or "",
|
||||
)
|
||||
|
||||
# Test connection
|
||||
try:
|
||||
result = await self._client.fetch("SELECT 1")
|
||||
logger.info(
|
||||
"DataStore connected",
|
||||
url=self._url,
|
||||
database=self._database,
|
||||
)
|
||||
self._initialized = True
|
||||
except Exception as e:
|
||||
logger.error("DataStore connection failed", error=str(e))
|
||||
raise
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the connection."""
|
||||
if self._session:
|
||||
await self._session.close()
|
||||
self._session = None
|
||||
self._client = None
|
||||
self._initialized = False
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
"""Check if connected to DataStore."""
|
||||
return self._initialized and self._client is not None
|
||||
|
||||
async def execute(self, query: str, params: dict[str, Any] | None = None) -> None:
|
||||
"""Execute a query without returning results."""
|
||||
if not self._client:
|
||||
raise RuntimeError("DataStore not initialized")
|
||||
await self._client.execute(query, params or {})
|
||||
|
||||
async def fetch(
|
||||
self, query: str, params: dict[str, Any] | None = None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Execute a query and return results as list of dicts."""
|
||||
if not self._client:
|
||||
raise RuntimeError("DataStore not initialized")
|
||||
rows = await self._client.fetch(query, params or {})
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
async def fetchone(
|
||||
self, query: str, params: dict[str, Any] | None = None
|
||||
) -> dict[str, Any] | None:
|
||||
"""Execute a query and return first result."""
|
||||
results = await self.fetch(query, params)
|
||||
return results[0] if results else None
|
||||
|
||||
async def fetchval(
|
||||
self, query: str, params: dict[str, Any] | None = None
|
||||
) -> Any:
|
||||
"""Execute a query and return single value."""
|
||||
if not self._client:
|
||||
raise RuntimeError("DataStore not initialized")
|
||||
return await self._client.fetchval(query, params or {})
|
||||
|
||||
async def insert(
|
||||
self,
|
||||
table: str,
|
||||
data: list[dict[str, Any]],
|
||||
) -> None:
|
||||
"""Insert rows into a table."""
|
||||
if not self._client or not data:
|
||||
return
|
||||
|
||||
columns = list(data[0].keys())
|
||||
values_list = [[row[col] for col in columns] for row in data]
|
||||
|
||||
query = f"INSERT INTO {table} ({', '.join(columns)}) VALUES"
|
||||
await self._client.execute(query, *values_list)
|
||||
|
||||
async def insert_one(self, table: str, data: dict[str, Any]) -> None:
|
||||
"""Insert a single row."""
|
||||
await self.insert(table, [data])
|
||||
|
||||
|
||||
# Global singleton instance
|
||||
datastore_client = DataStoreClient()
|
||||
@@ -0,0 +1,265 @@
|
||||
"""DataStore query functions for blockchain analytics."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from .client import datastore_client
|
||||
|
||||
|
||||
async def get_blocks(
|
||||
chain_id: int,
|
||||
network: str = "mainnet",
|
||||
from_block: int | None = None,
|
||||
to_block: int | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Get blocks from DataStore."""
|
||||
conditions = ["chain_id = {chain_id:UInt64}", "network = {network:String}"]
|
||||
params: dict[str, Any] = {"chain_id": chain_id, "network": network}
|
||||
|
||||
if from_block is not None:
|
||||
conditions.append("block_number >= {from_block:UInt64}")
|
||||
params["from_block"] = from_block
|
||||
if to_block is not None:
|
||||
conditions.append("block_number <= {to_block:UInt64}")
|
||||
params["to_block"] = to_block
|
||||
|
||||
query = f"""
|
||||
SELECT *
|
||||
FROM blocks
|
||||
WHERE {' AND '.join(conditions)}
|
||||
ORDER BY block_number DESC
|
||||
LIMIT {limit} OFFSET {offset}
|
||||
"""
|
||||
return await datastore_client.fetch(query, params)
|
||||
|
||||
|
||||
async def get_transactions(
|
||||
chain_id: int,
|
||||
network: str = "mainnet",
|
||||
address: str | None = None,
|
||||
from_block: int | None = None,
|
||||
to_block: int | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Get transactions from DataStore."""
|
||||
conditions = ["chain_id = {chain_id:UInt64}", "network = {network:String}"]
|
||||
params: dict[str, Any] = {"chain_id": chain_id, "network": network}
|
||||
|
||||
if address:
|
||||
conditions.append("(from_address = {address:String} OR to_address = {address:String})")
|
||||
params["address"] = address.lower()
|
||||
if from_block is not None:
|
||||
conditions.append("block_number >= {from_block:UInt64}")
|
||||
params["from_block"] = from_block
|
||||
if to_block is not None:
|
||||
conditions.append("block_number <= {to_block:UInt64}")
|
||||
params["to_block"] = to_block
|
||||
|
||||
query = f"""
|
||||
SELECT *
|
||||
FROM transactions
|
||||
WHERE {' AND '.join(conditions)}
|
||||
ORDER BY block_number DESC, tx_index DESC
|
||||
LIMIT {limit} OFFSET {offset}
|
||||
"""
|
||||
return await datastore_client.fetch(query, params)
|
||||
|
||||
|
||||
async def get_token_transfers(
|
||||
chain_id: int,
|
||||
network: str = "mainnet",
|
||||
address: str | None = None,
|
||||
token_address: str | None = None,
|
||||
from_block: int | None = None,
|
||||
to_block: int | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Get ERC-20 token transfers from DataStore."""
|
||||
conditions = ["chain_id = {chain_id:UInt64}", "network = {network:String}"]
|
||||
params: dict[str, Any] = {"chain_id": chain_id, "network": network}
|
||||
|
||||
if address:
|
||||
conditions.append("(from_address = {address:String} OR to_address = {address:String})")
|
||||
params["address"] = address.lower()
|
||||
if token_address:
|
||||
conditions.append("token_address = {token_address:String}")
|
||||
params["token_address"] = token_address.lower()
|
||||
if from_block is not None:
|
||||
conditions.append("block_number >= {from_block:UInt64}")
|
||||
params["from_block"] = from_block
|
||||
if to_block is not None:
|
||||
conditions.append("block_number <= {to_block:UInt64}")
|
||||
params["to_block"] = to_block
|
||||
|
||||
query = f"""
|
||||
SELECT *
|
||||
FROM token_transfers
|
||||
WHERE {' AND '.join(conditions)}
|
||||
ORDER BY block_number DESC, log_index DESC
|
||||
LIMIT {limit} OFFSET {offset}
|
||||
"""
|
||||
return await datastore_client.fetch(query, params)
|
||||
|
||||
|
||||
async def get_nft_transfers(
|
||||
chain_id: int,
|
||||
network: str = "mainnet",
|
||||
address: str | None = None,
|
||||
contract_address: str | None = None,
|
||||
token_id: int | None = None,
|
||||
from_block: int | None = None,
|
||||
to_block: int | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Get NFT transfers from DataStore."""
|
||||
conditions = ["chain_id = {chain_id:UInt64}", "network = {network:String}"]
|
||||
params: dict[str, Any] = {"chain_id": chain_id, "network": network}
|
||||
|
||||
if address:
|
||||
conditions.append("(from_address = {address:String} OR to_address = {address:String})")
|
||||
params["address"] = address.lower()
|
||||
if contract_address:
|
||||
conditions.append("contract_address = {contract_address:String}")
|
||||
params["contract_address"] = contract_address.lower()
|
||||
if token_id is not None:
|
||||
conditions.append("token_id = {token_id:UInt256}")
|
||||
params["token_id"] = token_id
|
||||
if from_block is not None:
|
||||
conditions.append("block_number >= {from_block:UInt64}")
|
||||
params["from_block"] = from_block
|
||||
if to_block is not None:
|
||||
conditions.append("block_number <= {to_block:UInt64}")
|
||||
params["to_block"] = to_block
|
||||
|
||||
query = f"""
|
||||
SELECT *
|
||||
FROM nft_transfers
|
||||
WHERE {' AND '.join(conditions)}
|
||||
ORDER BY block_number DESC, log_index DESC
|
||||
LIMIT {limit} OFFSET {offset}
|
||||
"""
|
||||
return await datastore_client.fetch(query, params)
|
||||
|
||||
|
||||
async def get_address_activity(
|
||||
chain_id: int,
|
||||
address: str,
|
||||
network: str = "mainnet",
|
||||
from_date: datetime | None = None,
|
||||
to_date: datetime | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Get aggregated address activity from DataStore."""
|
||||
conditions = [
|
||||
"chain_id = {chain_id:UInt64}",
|
||||
"network = {network:String}",
|
||||
"address = {address:String}",
|
||||
]
|
||||
params: dict[str, Any] = {
|
||||
"chain_id": chain_id,
|
||||
"network": network,
|
||||
"address": address.lower(),
|
||||
}
|
||||
|
||||
if from_date:
|
||||
conditions.append("date >= {from_date:Date}")
|
||||
params["from_date"] = from_date.date()
|
||||
if to_date:
|
||||
conditions.append("date <= {to_date:Date}")
|
||||
params["to_date"] = to_date.date()
|
||||
|
||||
query = f"""
|
||||
SELECT *
|
||||
FROM address_activity
|
||||
WHERE {' AND '.join(conditions)}
|
||||
ORDER BY date DESC
|
||||
"""
|
||||
return await datastore_client.fetch(query, params)
|
||||
|
||||
|
||||
async def get_api_usage(
|
||||
project_id: UUID,
|
||||
from_date: datetime | None = None,
|
||||
to_date: datetime | None = None,
|
||||
chain_id: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Get API usage statistics for a project."""
|
||||
conditions = ["project_id = {project_id:UUID}"]
|
||||
params: dict[str, Any] = {"project_id": str(project_id)}
|
||||
|
||||
if from_date:
|
||||
conditions.append("timestamp >= {from_date:DateTime64(3)}")
|
||||
params["from_date"] = from_date
|
||||
if to_date:
|
||||
conditions.append("timestamp <= {to_date:DateTime64(3)}")
|
||||
params["to_date"] = to_date
|
||||
if chain_id:
|
||||
conditions.append("chain_id = {chain_id:UInt64}")
|
||||
params["chain_id"] = chain_id
|
||||
|
||||
query = f"""
|
||||
SELECT
|
||||
count() as total_requests,
|
||||
sum(compute_units) as total_compute_units,
|
||||
avg(response_time_ms) as avg_response_time_ms,
|
||||
countIf(status_code >= 400) as error_count,
|
||||
uniq(endpoint) as unique_endpoints,
|
||||
uniq(chain_id) as chains_used
|
||||
FROM api_usage
|
||||
WHERE {' AND '.join(conditions)}
|
||||
"""
|
||||
return await datastore_client.fetchone(query, params) or {}
|
||||
|
||||
|
||||
async def record_api_usage(
|
||||
project_id: UUID,
|
||||
api_key_id: UUID,
|
||||
chain_id: int,
|
||||
network: str,
|
||||
endpoint: str,
|
||||
method: str,
|
||||
compute_units: int,
|
||||
response_time_ms: int,
|
||||
status_code: int,
|
||||
ip_address: str = "",
|
||||
user_agent: str = "",
|
||||
) -> None:
|
||||
"""Record API usage to DataStore."""
|
||||
await datastore_client.insert_one(
|
||||
"api_usage",
|
||||
{
|
||||
"project_id": str(project_id),
|
||||
"api_key_id": str(api_key_id),
|
||||
"chain_id": chain_id,
|
||||
"network": network,
|
||||
"endpoint": endpoint,
|
||||
"method": method,
|
||||
"compute_units": compute_units,
|
||||
"response_time_ms": response_time_ms,
|
||||
"status_code": status_code,
|
||||
"ip_address": ip_address,
|
||||
"user_agent": user_agent,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def get_chain_stats(chain_id: int, network: str = "mainnet") -> dict[str, Any]:
|
||||
"""Get chain statistics from DataStore."""
|
||||
query = """
|
||||
SELECT
|
||||
max(block_number) as latest_block,
|
||||
count() as total_transactions_24h,
|
||||
uniq(from_address) as unique_addresses_24h
|
||||
FROM transactions
|
||||
WHERE chain_id = {chain_id:UInt64}
|
||||
AND network = {network:String}
|
||||
AND timestamp >= now() - INTERVAL 24 HOUR
|
||||
"""
|
||||
return await datastore_client.fetchone(
|
||||
query, {"chain_id": chain_id, "network": network}
|
||||
) or {}
|
||||
@@ -0,0 +1,231 @@
|
||||
"""Hanzo IAM integration for multi-tenant authentication.
|
||||
|
||||
Supports login via:
|
||||
- hanzo.id (Hanzo org)
|
||||
- zoo.id (Zoo Labs org)
|
||||
- lux.id (Lux Network org)
|
||||
- pars.id (Pars org)
|
||||
|
||||
Uses Casdoor-compatible OAuth2/OIDC flow.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
from datetime import datetime, timezone
|
||||
import jwt
|
||||
from jwt import PyJWKClient
|
||||
from pydantic import BaseModel
|
||||
from fastapi import HTTPException, status, Depends
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
|
||||
from bootnode.config import get_settings
|
||||
|
||||
|
||||
class IAMUser(BaseModel):
|
||||
"""User from Hanzo IAM."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
email: str
|
||||
org: str # hanzo, zoo, lux, pars
|
||||
avatar: str | None = None
|
||||
roles: list[str] = []
|
||||
permissions: list[str] = []
|
||||
created_at: datetime | None = None
|
||||
|
||||
@property
|
||||
def is_admin(self) -> bool:
|
||||
"""Check if user has admin role."""
|
||||
return "admin" in self.roles
|
||||
|
||||
@property
|
||||
def can_create_projects(self) -> bool:
|
||||
"""Check if user can create projects."""
|
||||
return "projects:write" in self.permissions or self.is_admin
|
||||
|
||||
|
||||
class IAMClient:
|
||||
"""Client for Hanzo IAM service."""
|
||||
|
||||
def __init__(self):
|
||||
self.settings = get_settings()
|
||||
self._jwks_client: PyJWKClient | None = None
|
||||
|
||||
@property
|
||||
def jwks_client(self) -> PyJWKClient:
|
||||
"""Get JWKS client (lazy initialization)."""
|
||||
if self._jwks_client is None:
|
||||
jwks_url = f"{self.settings.iam_url}/.well-known/jwks.json"
|
||||
self._jwks_client = PyJWKClient(jwks_url)
|
||||
return self._jwks_client
|
||||
|
||||
async def verify_token(self, token: str) -> IAMUser:
|
||||
"""Verify JWT token from hanzo.id and return user."""
|
||||
try:
|
||||
# Get signing key from JWKS
|
||||
signing_key = self.jwks_client.get_signing_key_from_jwt(token)
|
||||
|
||||
# Decode and verify token
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
signing_key.key,
|
||||
algorithms=["RS256", "ES256"],
|
||||
audience=self.settings.iam_client_id,
|
||||
issuer=self.settings.iam_url,
|
||||
)
|
||||
|
||||
# Extract user info
|
||||
return IAMUser(
|
||||
id=payload.get("sub"),
|
||||
name=payload.get("name", ""),
|
||||
email=payload.get("email", ""),
|
||||
org=payload.get("org", "hanzo"),
|
||||
avatar=payload.get("avatar"),
|
||||
roles=payload.get("roles", []),
|
||||
permissions=payload.get("permissions", []),
|
||||
created_at=datetime.fromtimestamp(
|
||||
payload.get("iat", 0), tz=timezone.utc
|
||||
),
|
||||
)
|
||||
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token has expired",
|
||||
)
|
||||
except jwt.InvalidTokenError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=f"Invalid token: {str(e)}",
|
||||
)
|
||||
|
||||
async def exchange_code(self, code: str, redirect_uri: str) -> dict[str, Any]:
|
||||
"""Exchange authorization code for tokens."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
f"{self.settings.iam_url}/api/login/oauth/access_token",
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": self.settings.iam_client_id,
|
||||
"client_secret": self.settings.iam_client_secret,
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri,
|
||||
},
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Failed to exchange code for token",
|
||||
)
|
||||
|
||||
return response.json()
|
||||
|
||||
async def get_user_info(self, access_token: str) -> IAMUser:
|
||||
"""Get user info from IAM using access token."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
f"{self.settings.iam_url}/api/userinfo",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Failed to get user info",
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
return IAMUser(
|
||||
id=data.get("sub"),
|
||||
name=data.get("name", ""),
|
||||
email=data.get("email", ""),
|
||||
org=data.get("org", "hanzo"),
|
||||
avatar=data.get("avatar"),
|
||||
roles=data.get("roles", []),
|
||||
permissions=data.get("permissions", []),
|
||||
)
|
||||
|
||||
def get_login_url(self, redirect_uri: str, org: str = "hanzo") -> str:
|
||||
"""Get login URL for specific org."""
|
||||
org_domains = {
|
||||
"hanzo": "hanzo.id",
|
||||
"zoo": "zoo.id",
|
||||
"lux": "lux.id",
|
||||
"pars": "pars.id",
|
||||
}
|
||||
domain = org_domains.get(org, "hanzo.id")
|
||||
|
||||
return (
|
||||
f"https://{domain}/login/oauth/authorize"
|
||||
f"?client_id={self.settings.iam_client_id}"
|
||||
f"&response_type=code"
|
||||
f"&redirect_uri={redirect_uri}"
|
||||
f"&scope=openid+profile+email"
|
||||
f"&state={org}"
|
||||
)
|
||||
|
||||
|
||||
# Dependency injection
|
||||
@lru_cache()
|
||||
def get_iam_client() -> IAMClient:
|
||||
"""Get IAM client singleton."""
|
||||
return IAMClient()
|
||||
|
||||
|
||||
# Security scheme
|
||||
bearer_scheme = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
||||
iam: IAMClient = Depends(get_iam_client),
|
||||
) -> IAMUser:
|
||||
"""Get current authenticated user from hanzo.id token."""
|
||||
if credentials is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Not authenticated",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
return await iam.verify_token(credentials.credentials)
|
||||
|
||||
|
||||
async def get_optional_user(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
||||
iam: IAMClient = Depends(get_iam_client),
|
||||
) -> IAMUser | None:
|
||||
"""Get current user if authenticated, None otherwise."""
|
||||
if credentials is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
return await iam.verify_token(credentials.credentials)
|
||||
except HTTPException:
|
||||
return None
|
||||
|
||||
|
||||
def require_org(allowed_orgs: list[str]):
|
||||
"""Dependency that requires user to be from specific org(s)."""
|
||||
async def _require_org(user: IAMUser = Depends(get_current_user)) -> IAMUser:
|
||||
if user.org not in allowed_orgs:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Access restricted to orgs: {allowed_orgs}",
|
||||
)
|
||||
return user
|
||||
return _require_org
|
||||
|
||||
|
||||
def require_permission(permission: str):
|
||||
"""Dependency that requires user to have specific permission."""
|
||||
async def _require_permission(user: IAMUser = Depends(get_current_user)) -> IAMUser:
|
||||
if permission not in user.permissions and not user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Missing permission: {permission}",
|
||||
)
|
||||
return user
|
||||
return _require_permission
|
||||
@@ -0,0 +1,20 @@
|
||||
"""
|
||||
Event Indexer Module
|
||||
|
||||
This module will handle blockchain event indexing for:
|
||||
- ERC-20 Transfer events
|
||||
- ERC-721 Transfer events
|
||||
- ERC-1155 TransferSingle/TransferBatch events
|
||||
- Custom contract events for webhooks
|
||||
|
||||
Implementation TODOs:
|
||||
1. Block listener that tracks the latest block for each chain
|
||||
2. Event parser that decodes logs based on event signatures
|
||||
3. Storage layer for indexed events (for Transfers API, NFT ownership, etc.)
|
||||
4. Webhook trigger system that matches events against webhook filters
|
||||
|
||||
For high-performance indexing, consider using:
|
||||
- Rust-based indexer (see /indexer directory)
|
||||
- Subgraph-style indexing with TheGraph
|
||||
- Custom event listener with backfill capability
|
||||
"""
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Database module."""
|
||||
|
||||
from bootnode.db.session import get_db, engine, async_session
|
||||
from bootnode.db.models import Base, Project, ApiKey, Webhook, Usage
|
||||
|
||||
__all__ = ["get_db", "engine", "async_session", "Base", "Project", "ApiKey", "Webhook", "Usage"]
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Database models."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Base class for all models."""
|
||||
|
||||
type_annotation_map = {
|
||||
dict[str, Any]: JSONB,
|
||||
}
|
||||
|
||||
|
||||
class Project(Base):
|
||||
"""Project/Organization model."""
|
||||
|
||||
__tablename__ = "projects"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
owner_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
# Relationships
|
||||
api_keys: Mapped[list["ApiKey"]] = relationship(back_populates="project")
|
||||
webhooks: Mapped[list["Webhook"]] = relationship(back_populates="project")
|
||||
|
||||
|
||||
class ApiKey(Base):
|
||||
"""API Key model."""
|
||||
|
||||
__tablename__ = "api_keys"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
project_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
key_hash: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
||||
key_prefix: Mapped[str] = mapped_column(String(12), nullable=False) # First chars for display
|
||||
rate_limit: Mapped[int] = mapped_column(Integer, default=100) # requests per minute
|
||||
compute_units_limit: Mapped[int] = mapped_column(Integer, default=1000) # CU per minute
|
||||
allowed_origins: Mapped[list[str] | None] = mapped_column(JSONB, nullable=True)
|
||||
allowed_chains: Mapped[list[str] | None] = mapped_column(JSONB, nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
||||
# Relationships
|
||||
project: Mapped["Project"] = relationship(back_populates="api_keys")
|
||||
|
||||
|
||||
class Webhook(Base):
|
||||
"""Webhook subscription model."""
|
||||
|
||||
__tablename__ = "webhooks"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
project_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
url: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
chain: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
network: Mapped[str] = mapped_column(String(50), nullable=False, default="mainnet")
|
||||
event_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
filters: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
|
||||
secret: Mapped[str] = mapped_column(String(255), nullable=False) # For HMAC signing
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
failure_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
last_triggered_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
||||
# Relationships
|
||||
project: Mapped["Project"] = relationship(back_populates="webhooks")
|
||||
deliveries: Mapped[list["WebhookDelivery"]] = relationship(back_populates="webhook")
|
||||
|
||||
|
||||
class WebhookDelivery(Base):
|
||||
"""Webhook delivery attempt model."""
|
||||
|
||||
__tablename__ = "webhook_deliveries"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
webhook_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("webhooks.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
payload: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
|
||||
status_code: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
response_body: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
attempt_count: Mapped[int] = mapped_column(Integer, default=1)
|
||||
success: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
delivered_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
||||
# Relationships
|
||||
webhook: Mapped["Webhook"] = relationship(back_populates="deliveries")
|
||||
|
||||
|
||||
class Usage(Base):
|
||||
"""API usage tracking model."""
|
||||
|
||||
__tablename__ = "usage"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
project_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
api_key_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("api_keys.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
chain: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
network: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
method: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
compute_units: Mapped[int] = mapped_column(Integer, default=1)
|
||||
response_time_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
status_code: Mapped[int] = mapped_column(Integer, default=200)
|
||||
timestamp: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
||||
|
||||
class SmartWallet(Base):
|
||||
"""ERC-4337 Smart Wallet model."""
|
||||
|
||||
__tablename__ = "smart_wallets"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
project_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
address: Mapped[str] = mapped_column(String(42), nullable=False, unique=True)
|
||||
owner_address: Mapped[str] = mapped_column(String(42), nullable=False)
|
||||
factory_address: Mapped[str] = mapped_column(String(42), nullable=False)
|
||||
chain: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
network: Mapped[str] = mapped_column(String(50), nullable=False, default="mainnet")
|
||||
salt: Mapped[str] = mapped_column(String(66), nullable=False) # bytes32 hex
|
||||
is_deployed: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
metadata: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
||||
|
||||
class GasPolicy(Base):
|
||||
"""Gas sponsorship policy model."""
|
||||
|
||||
__tablename__ = "gas_policies"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
project_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
chain: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
network: Mapped[str] = mapped_column(String(50), nullable=False, default="mainnet")
|
||||
rules: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
|
||||
max_gas_per_op: Mapped[int] = mapped_column(Integer, default=1000000) # gas units
|
||||
max_spend_per_day_usd: Mapped[int] = mapped_column(Integer, default=100) # in cents
|
||||
allowed_contracts: Mapped[list[str] | None] = mapped_column(JSONB, nullable=True)
|
||||
allowed_methods: Mapped[list[str] | None] = mapped_column(JSONB, nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Database session management."""
|
||||
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from bootnode.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
engine = create_async_engine(
|
||||
str(settings.database_url),
|
||||
pool_size=settings.db_pool_size,
|
||||
max_overflow=settings.db_max_overflow,
|
||||
echo=settings.debug,
|
||||
)
|
||||
|
||||
async_session = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
"""Initialize database tables."""
|
||||
from bootnode.db.models import Base
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Get database session dependency."""
|
||||
async with async_session() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Bootnode API - Main application entry point."""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncGenerator
|
||||
|
||||
import structlog
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import ORJSONResponse
|
||||
|
||||
from bootnode.config import get_settings
|
||||
from bootnode.api import router as api_router
|
||||
from bootnode.ws import router as ws_router
|
||||
from bootnode.db.session import engine, init_db
|
||||
from bootnode.core.cache import redis_client
|
||||
from bootnode.core.datastore import datastore_client
|
||||
|
||||
logger = structlog.get_logger()
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""Application lifespan handler."""
|
||||
# Startup
|
||||
logger.info("Starting Bootnode API", env=settings.app_env)
|
||||
await init_db()
|
||||
await redis_client.initialize()
|
||||
|
||||
# Initialize DataStore (ClickHouse) - optional, won't fail startup
|
||||
try:
|
||||
await datastore_client.initialize()
|
||||
logger.info("DataStore connected")
|
||||
except Exception as e:
|
||||
logger.warning("DataStore not available", error=str(e))
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
logger.info("Shutting down Bootnode API")
|
||||
await engine.dispose()
|
||||
await redis_client.close()
|
||||
await datastore_client.close()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Bootnode API",
|
||||
description="Blockchain Development Platform - Multi-chain RPC, Token API, NFT API, Smart Wallets, and more",
|
||||
version="2.0.0",
|
||||
docs_url="/docs" if not settings.is_production else None,
|
||||
redoc_url="/redoc" if not settings.is_production else None,
|
||||
openapi_url="/openapi.json" if not settings.is_production else None,
|
||||
default_response_class=ORJSONResponse,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"] if not settings.is_production else ["https://bootnode.dev"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Include API routes
|
||||
app.include_router(api_router, prefix=settings.api_prefix)
|
||||
|
||||
# Include WebSocket routes
|
||||
app.include_router(ws_router, prefix=f"{settings.api_prefix}/ws", tags=["WebSocket"])
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check() -> dict:
|
||||
"""Health check endpoint."""
|
||||
return {"status": "healthy", "version": "2.0.0"}
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root() -> dict:
|
||||
"""Root endpoint."""
|
||||
return {
|
||||
"name": "Bootnode API",
|
||||
"version": "2.0.0",
|
||||
"docs": "/docs",
|
||||
"status": "operational",
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""Background workers for Bootnode API."""
|
||||
@@ -0,0 +1,299 @@
|
||||
"""Webhook delivery worker.
|
||||
|
||||
Processes webhook events from Redis queue and delivers them to registered endpoints.
|
||||
Uses arq (async Redis queue) for job processing - compatible with @hanzo/mq patterns.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import structlog
|
||||
from arq import create_pool, cron
|
||||
from arq.connections import RedisSettings
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bootnode.config import get_settings
|
||||
from bootnode.db.session import AsyncSessionLocal
|
||||
from bootnode.db.models import Webhook, WebhookDelivery
|
||||
from bootnode.core.datastore import datastore_client
|
||||
|
||||
logger = structlog.get_logger()
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
async def sign_payload(payload: str, secret: str) -> str:
|
||||
"""Generate HMAC-SHA256 signature for webhook payload."""
|
||||
return hmac.new(
|
||||
secret.encode(),
|
||||
payload.encode(),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
|
||||
async def deliver_webhook(
|
||||
ctx: dict,
|
||||
webhook_id: str,
|
||||
event_type: str,
|
||||
payload: dict[str, Any],
|
||||
) -> bool:
|
||||
"""Deliver a webhook to its endpoint (arq job function)."""
|
||||
async with AsyncSessionLocal() as session:
|
||||
# Get webhook
|
||||
result = await session.execute(
|
||||
select(Webhook).where(Webhook.id == webhook_id)
|
||||
)
|
||||
webhook = result.scalar_one_or_none()
|
||||
|
||||
if not webhook:
|
||||
logger.warning("Webhook not found", webhook_id=webhook_id)
|
||||
return False
|
||||
|
||||
if not webhook.is_active:
|
||||
logger.info("Webhook is inactive", webhook_id=webhook_id)
|
||||
return False
|
||||
|
||||
payload_json = json.dumps(payload, default=str)
|
||||
signature = await sign_payload(payload_json, webhook.secret)
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Bootnode-Signature": f"sha256={signature}",
|
||||
"X-Bootnode-Event": event_type,
|
||||
"X-Bootnode-Webhook-ID": str(webhook.id),
|
||||
"X-Bootnode-Timestamp": str(int(time.time())),
|
||||
}
|
||||
|
||||
delivery = WebhookDelivery(
|
||||
webhook_id=webhook.id,
|
||||
event_type=event_type,
|
||||
payload=payload,
|
||||
status="pending",
|
||||
)
|
||||
session.add(delivery)
|
||||
await session.commit()
|
||||
|
||||
start_time = time.time()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(
|
||||
webhook.url,
|
||||
content=payload_json,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
response_time_ms = int((time.time() - start_time) * 1000)
|
||||
delivery.status = "success" if response.status_code < 400 else "failed"
|
||||
delivery.status_code = response.status_code
|
||||
delivery.response_time_ms = response_time_ms
|
||||
delivery.delivered_at = datetime.utcnow()
|
||||
|
||||
if response.status_code >= 400:
|
||||
delivery.error = f"HTTP {response.status_code}: {response.text[:500]}"
|
||||
webhook.failure_count += 1
|
||||
else:
|
||||
webhook.failure_count = 0
|
||||
|
||||
webhook.last_triggered_at = datetime.utcnow()
|
||||
await session.commit()
|
||||
|
||||
# Record to DataStore for analytics
|
||||
if datastore_client.is_connected:
|
||||
await datastore_client.insert_one(
|
||||
"webhook_deliveries",
|
||||
{
|
||||
"id": str(delivery.id),
|
||||
"webhook_id": str(webhook.id),
|
||||
"project_id": str(webhook.project_id),
|
||||
"chain_id": webhook.chain_id,
|
||||
"network": webhook.network,
|
||||
"event_type": event_type,
|
||||
"payload": payload_json,
|
||||
"status": delivery.status,
|
||||
"status_code": response.status_code,
|
||||
"response_time_ms": response_time_ms,
|
||||
"attempt": 1,
|
||||
"delivered_at": delivery.delivered_at,
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Webhook delivered",
|
||||
webhook_id=str(webhook.id),
|
||||
status=delivery.status,
|
||||
response_time_ms=response_time_ms,
|
||||
)
|
||||
return delivery.status == "success"
|
||||
|
||||
except Exception as e:
|
||||
response_time_ms = int((time.time() - start_time) * 1000)
|
||||
delivery.status = "failed"
|
||||
delivery.response_time_ms = response_time_ms
|
||||
delivery.error = str(e)
|
||||
webhook.failure_count += 1
|
||||
await session.commit()
|
||||
|
||||
logger.error(
|
||||
"Webhook delivery failed",
|
||||
webhook_id=str(webhook.id),
|
||||
url=webhook.url,
|
||||
error=str(e),
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def process_webhook_event(
|
||||
ctx: dict,
|
||||
event_type: str,
|
||||
chain_id: int,
|
||||
network: str,
|
||||
payload: dict[str, Any],
|
||||
filters: dict[str, Any] | None = None,
|
||||
) -> int:
|
||||
"""Process a webhook event and queue deliveries for matching webhooks."""
|
||||
filters = filters or {}
|
||||
delivered_count = 0
|
||||
|
||||
logger.info(
|
||||
"Processing webhook event",
|
||||
event_type=event_type,
|
||||
chain_id=chain_id,
|
||||
network=network,
|
||||
)
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
query = select(Webhook).where(
|
||||
Webhook.chain_id == chain_id,
|
||||
Webhook.network == network,
|
||||
Webhook.event_type == event_type,
|
||||
Webhook.is_active == True,
|
||||
Webhook.failure_count < 10, # Disable after 10 failures
|
||||
)
|
||||
result = await session.execute(query)
|
||||
webhooks = result.scalars().all()
|
||||
|
||||
redis = ctx.get("redis")
|
||||
for webhook in webhooks:
|
||||
# Check if webhook filters match the event
|
||||
if await matches_filters(webhook.filters, filters, payload):
|
||||
# Queue the delivery job
|
||||
await redis.enqueue_job(
|
||||
"deliver_webhook",
|
||||
str(webhook.id),
|
||||
event_type,
|
||||
payload,
|
||||
)
|
||||
delivered_count += 1
|
||||
|
||||
return delivered_count
|
||||
|
||||
|
||||
async def matches_filters(
|
||||
webhook_filters: dict[str, Any] | None,
|
||||
event_filters: dict[str, Any],
|
||||
payload: dict[str, Any],
|
||||
) -> bool:
|
||||
"""Check if webhook filters match the event."""
|
||||
if not webhook_filters:
|
||||
return True
|
||||
|
||||
# Address filter
|
||||
if "address" in webhook_filters:
|
||||
addresses = webhook_filters["address"]
|
||||
if isinstance(addresses, str):
|
||||
addresses = [addresses]
|
||||
addresses = [a.lower() for a in addresses]
|
||||
|
||||
event_addresses = []
|
||||
if "from" in payload:
|
||||
event_addresses.append(payload["from"].lower())
|
||||
if "to" in payload:
|
||||
event_addresses.append(payload["to"].lower())
|
||||
if "address" in payload:
|
||||
event_addresses.append(payload["address"].lower())
|
||||
|
||||
if not any(addr in addresses for addr in event_addresses):
|
||||
return False
|
||||
|
||||
# Contract filter
|
||||
if "contract" in webhook_filters:
|
||||
contracts = webhook_filters["contract"]
|
||||
if isinstance(contracts, str):
|
||||
contracts = [contracts]
|
||||
contracts = [c.lower() for c in contracts]
|
||||
|
||||
if payload.get("contract", "").lower() not in contracts:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def cleanup_old_deliveries(ctx: dict) -> int:
|
||||
"""Cleanup old webhook deliveries (cron job)."""
|
||||
async with AsyncSessionLocal() as session:
|
||||
cutoff = datetime.utcnow() - timedelta(days=30)
|
||||
# In production, implement proper cleanup
|
||||
logger.info("Cleanup job running", cutoff=cutoff)
|
||||
return 0
|
||||
|
||||
|
||||
async def startup(ctx: dict) -> None:
|
||||
"""Worker startup - initialize connections."""
|
||||
logger.info("Starting webhook worker")
|
||||
|
||||
# Initialize DataStore
|
||||
try:
|
||||
await datastore_client.initialize()
|
||||
logger.info("DataStore connected")
|
||||
except Exception as e:
|
||||
logger.warning("DataStore not available", error=str(e))
|
||||
|
||||
|
||||
async def shutdown(ctx: dict) -> None:
|
||||
"""Worker shutdown - cleanup connections."""
|
||||
logger.info("Shutting down webhook worker")
|
||||
await datastore_client.close()
|
||||
|
||||
|
||||
class WorkerSettings:
|
||||
"""arq worker settings."""
|
||||
|
||||
functions = [
|
||||
deliver_webhook,
|
||||
process_webhook_event,
|
||||
]
|
||||
|
||||
cron_jobs = [
|
||||
cron(cleanup_old_deliveries, hour=3, minute=0), # Run at 3 AM daily
|
||||
]
|
||||
|
||||
on_startup = startup
|
||||
on_shutdown = shutdown
|
||||
|
||||
# Redis connection settings
|
||||
redis_settings = RedisSettings.from_dsn(settings.redis_url)
|
||||
|
||||
# Worker settings
|
||||
max_jobs = 100
|
||||
job_timeout = 60
|
||||
keep_result = 3600 # Keep results for 1 hour
|
||||
queue_name = "bootnode:webhooks"
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""Main entry point for running as script."""
|
||||
from arq import run_worker
|
||||
|
||||
# Run the worker
|
||||
run_worker(WorkerSettings)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,366 @@
|
||||
"""WebSocket API - Real-time blockchain subscriptions."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any
|
||||
from collections import defaultdict
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
|
||||
|
||||
from bootnode.core.chains import ChainRegistry, RPCClient
|
||||
|
||||
router = APIRouter()
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
"""Manage WebSocket connections and subscriptions."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# connection_id -> WebSocket
|
||||
self.active_connections: dict[str, WebSocket] = {}
|
||||
# connection_id -> list of subscription_ids
|
||||
self.subscriptions: dict[str, list[str]] = defaultdict(list)
|
||||
# subscription_id -> subscription_info
|
||||
self.subscription_info: dict[str, dict[str, Any]] = {}
|
||||
|
||||
async def connect(self, websocket: WebSocket, connection_id: str) -> None:
|
||||
await websocket.accept()
|
||||
self.active_connections[connection_id] = websocket
|
||||
logger.info("WebSocket connected", connection_id=connection_id)
|
||||
|
||||
def disconnect(self, connection_id: str) -> None:
|
||||
if connection_id in self.active_connections:
|
||||
del self.active_connections[connection_id]
|
||||
# Clean up subscriptions
|
||||
for sub_id in self.subscriptions.get(connection_id, []):
|
||||
if sub_id in self.subscription_info:
|
||||
del self.subscription_info[sub_id]
|
||||
if connection_id in self.subscriptions:
|
||||
del self.subscriptions[connection_id]
|
||||
logger.info("WebSocket disconnected", connection_id=connection_id)
|
||||
|
||||
async def send_message(self, connection_id: str, message: dict[str, Any]) -> None:
|
||||
if connection_id in self.active_connections:
|
||||
await self.active_connections[connection_id].send_json(message)
|
||||
|
||||
def add_subscription(
|
||||
self,
|
||||
connection_id: str,
|
||||
subscription_id: str,
|
||||
subscription_type: str,
|
||||
params: dict[str, Any],
|
||||
) -> None:
|
||||
self.subscriptions[connection_id].append(subscription_id)
|
||||
self.subscription_info[subscription_id] = {
|
||||
"connection_id": connection_id,
|
||||
"type": subscription_type,
|
||||
"params": params,
|
||||
}
|
||||
|
||||
def remove_subscription(self, connection_id: str, subscription_id: str) -> bool:
|
||||
if subscription_id in self.subscription_info:
|
||||
del self.subscription_info[subscription_id]
|
||||
self.subscriptions[connection_id].remove(subscription_id)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
manager = ConnectionManager()
|
||||
|
||||
|
||||
@router.websocket("/{chain}")
|
||||
@router.websocket("/{chain}/{network}")
|
||||
async def websocket_endpoint(
|
||||
websocket: WebSocket,
|
||||
chain: str,
|
||||
network: str = "mainnet",
|
||||
api_key: str = Query(default=""),
|
||||
) -> None:
|
||||
"""
|
||||
WebSocket endpoint for real-time blockchain subscriptions.
|
||||
|
||||
Supports standard Ethereum subscription methods:
|
||||
- eth_subscribe: Subscribe to events
|
||||
- eth_unsubscribe: Unsubscribe from events
|
||||
|
||||
Subscription types:
|
||||
- newHeads: New block headers
|
||||
- newPendingTransactions: Pending transactions
|
||||
- logs: Contract event logs
|
||||
"""
|
||||
if not ChainRegistry.is_supported(chain, network):
|
||||
await websocket.close(code=4000, reason=f"Unsupported chain: {chain}/{network}")
|
||||
return
|
||||
|
||||
if not api_key:
|
||||
await websocket.close(code=4001, reason="API key required")
|
||||
return
|
||||
|
||||
# Generate connection ID
|
||||
import uuid
|
||||
connection_id = str(uuid.uuid4())
|
||||
|
||||
await manager.connect(websocket, connection_id)
|
||||
|
||||
try:
|
||||
while True:
|
||||
# Receive message
|
||||
data = await websocket.receive_text()
|
||||
try:
|
||||
request = json.loads(data)
|
||||
except json.JSONDecodeError:
|
||||
await manager.send_message(connection_id, {
|
||||
"jsonrpc": "2.0",
|
||||
"error": {"code": -32700, "message": "Parse error"},
|
||||
"id": None,
|
||||
})
|
||||
continue
|
||||
|
||||
# Handle request
|
||||
response = await handle_ws_request(
|
||||
chain, network, request, connection_id
|
||||
)
|
||||
await manager.send_message(connection_id, response)
|
||||
|
||||
except WebSocketDisconnect:
|
||||
manager.disconnect(connection_id)
|
||||
except Exception as e:
|
||||
logger.error("WebSocket error", error=str(e), connection_id=connection_id)
|
||||
manager.disconnect(connection_id)
|
||||
|
||||
|
||||
async def handle_ws_request(
|
||||
chain: str,
|
||||
network: str,
|
||||
request: dict[str, Any],
|
||||
connection_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Handle a WebSocket JSON-RPC request."""
|
||||
method = request.get("method", "")
|
||||
params = request.get("params", [])
|
||||
request_id = request.get("id")
|
||||
|
||||
if method == "eth_subscribe":
|
||||
return await handle_subscribe(chain, network, params, request_id, connection_id)
|
||||
elif method == "eth_unsubscribe":
|
||||
return await handle_unsubscribe(params, request_id, connection_id)
|
||||
else:
|
||||
# Forward other methods to RPC
|
||||
try:
|
||||
async with RPCClient(chain, network) as client:
|
||||
result = await client.call(method, params, request_id or 1)
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"result": result,
|
||||
"id": request_id,
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"error": {"code": -32603, "message": str(e)},
|
||||
"id": request_id,
|
||||
}
|
||||
|
||||
|
||||
async def handle_subscribe(
|
||||
chain: str,
|
||||
network: str,
|
||||
params: list[Any],
|
||||
request_id: Any,
|
||||
connection_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Handle eth_subscribe."""
|
||||
if not params:
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"error": {"code": -32602, "message": "Missing subscription type"},
|
||||
"id": request_id,
|
||||
}
|
||||
|
||||
sub_type = params[0]
|
||||
sub_params = params[1] if len(params) > 1 else {}
|
||||
|
||||
# Generate subscription ID
|
||||
import secrets
|
||||
subscription_id = "0x" + secrets.token_hex(16)
|
||||
|
||||
# Register subscription
|
||||
manager.add_subscription(
|
||||
connection_id,
|
||||
subscription_id,
|
||||
sub_type,
|
||||
{"chain": chain, "network": network, **sub_params},
|
||||
)
|
||||
|
||||
# Start subscription task
|
||||
if sub_type == "newHeads":
|
||||
asyncio.create_task(
|
||||
poll_new_heads(chain, network, connection_id, subscription_id)
|
||||
)
|
||||
elif sub_type == "newPendingTransactions":
|
||||
asyncio.create_task(
|
||||
poll_pending_transactions(chain, network, connection_id, subscription_id)
|
||||
)
|
||||
elif sub_type == "logs":
|
||||
asyncio.create_task(
|
||||
poll_logs(chain, network, connection_id, subscription_id, sub_params)
|
||||
)
|
||||
else:
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"error": {"code": -32602, "message": f"Unknown subscription type: {sub_type}"},
|
||||
"id": request_id,
|
||||
}
|
||||
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"result": subscription_id,
|
||||
"id": request_id,
|
||||
}
|
||||
|
||||
|
||||
async def handle_unsubscribe(
|
||||
params: list[Any],
|
||||
request_id: Any,
|
||||
connection_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Handle eth_unsubscribe."""
|
||||
if not params:
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"error": {"code": -32602, "message": "Missing subscription ID"},
|
||||
"id": request_id,
|
||||
}
|
||||
|
||||
subscription_id = params[0]
|
||||
success = manager.remove_subscription(connection_id, subscription_id)
|
||||
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"result": success,
|
||||
"id": request_id,
|
||||
}
|
||||
|
||||
|
||||
async def poll_new_heads(
|
||||
chain: str,
|
||||
network: str,
|
||||
connection_id: str,
|
||||
subscription_id: str,
|
||||
) -> None:
|
||||
"""Poll for new block headers."""
|
||||
last_block = 0
|
||||
|
||||
while subscription_id in manager.subscription_info:
|
||||
try:
|
||||
async with RPCClient(chain, network) as client:
|
||||
current_block = await client.get_block_number()
|
||||
|
||||
if current_block > last_block:
|
||||
# Get the new block
|
||||
block = await client.call(
|
||||
"eth_getBlockByNumber",
|
||||
[hex(current_block), False],
|
||||
)
|
||||
|
||||
if block:
|
||||
await manager.send_message(connection_id, {
|
||||
"jsonrpc": "2.0",
|
||||
"method": "eth_subscription",
|
||||
"params": {
|
||||
"subscription": subscription_id,
|
||||
"result": block,
|
||||
},
|
||||
})
|
||||
|
||||
last_block = current_block
|
||||
|
||||
await asyncio.sleep(1) # Poll every second
|
||||
|
||||
except Exception as e:
|
||||
logger.error("newHeads poll error", error=str(e))
|
||||
await asyncio.sleep(5)
|
||||
|
||||
|
||||
async def poll_pending_transactions(
|
||||
chain: str,
|
||||
network: str,
|
||||
connection_id: str,
|
||||
subscription_id: str,
|
||||
) -> None:
|
||||
"""Poll for pending transactions (simplified - uses filter)."""
|
||||
filter_id = None
|
||||
|
||||
try:
|
||||
async with RPCClient(chain, network) as client:
|
||||
# Create pending transaction filter
|
||||
filter_id = await client.call("eth_newPendingTransactionFilter", [])
|
||||
|
||||
while subscription_id in manager.subscription_info:
|
||||
try:
|
||||
# Get filter changes
|
||||
tx_hashes = await client.call("eth_getFilterChanges", [filter_id])
|
||||
|
||||
for tx_hash in tx_hashes or []:
|
||||
await manager.send_message(connection_id, {
|
||||
"jsonrpc": "2.0",
|
||||
"method": "eth_subscription",
|
||||
"params": {
|
||||
"subscription": subscription_id,
|
||||
"result": tx_hash,
|
||||
},
|
||||
})
|
||||
|
||||
await asyncio.sleep(1)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("pendingTx poll error", error=str(e))
|
||||
await asyncio.sleep(5)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("pendingTx filter error", error=str(e))
|
||||
|
||||
|
||||
async def poll_logs(
|
||||
chain: str,
|
||||
network: str,
|
||||
connection_id: str,
|
||||
subscription_id: str,
|
||||
params: dict[str, Any],
|
||||
) -> None:
|
||||
"""Poll for logs matching filter."""
|
||||
last_block = 0
|
||||
|
||||
while subscription_id in manager.subscription_info:
|
||||
try:
|
||||
async with RPCClient(chain, network) as client:
|
||||
current_block = await client.get_block_number()
|
||||
|
||||
if current_block > last_block:
|
||||
# Get logs for new blocks
|
||||
logs = await client.get_logs(
|
||||
from_block=last_block + 1 if last_block else current_block,
|
||||
to_block=current_block,
|
||||
address=params.get("address"),
|
||||
topics=params.get("topics"),
|
||||
)
|
||||
|
||||
for log in logs or []:
|
||||
await manager.send_message(connection_id, {
|
||||
"jsonrpc": "2.0",
|
||||
"method": "eth_subscription",
|
||||
"params": {
|
||||
"subscription": subscription_id,
|
||||
"result": log,
|
||||
},
|
||||
})
|
||||
|
||||
last_block = current_block
|
||||
|
||||
await asyncio.sleep(2) # Poll every 2 seconds
|
||||
|
||||
except Exception as e:
|
||||
logger.error("logs poll error", error=str(e))
|
||||
await asyncio.sleep(5)
|
||||
@@ -0,0 +1,101 @@
|
||||
[project]
|
||||
name = "bootnode"
|
||||
version = "2.0.0"
|
||||
description = "Blockchain Development Platform - Multi-chain RPC, Token API, NFT API, Smart Wallets, and more"
|
||||
readme = "README.md"
|
||||
license = { text = "BSD-3-Clause" }
|
||||
requires-python = ">=3.12"
|
||||
authors = [{ name = "Hanzo AI", email = "[email protected]" }]
|
||||
keywords = ["blockchain", "ethereum", "web3", "rpc", "nft", "erc4337"]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Framework :: FastAPI",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: BSD License",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Topic :: Internet :: WWW/HTTP :: HTTP Servers",
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
"fastapi>=0.115.0",
|
||||
"uvicorn[standard]>=0.32.0",
|
||||
"pydantic>=2.10.0",
|
||||
"pydantic-settings>=2.6.0",
|
||||
"sqlalchemy[asyncio]>=2.0.36",
|
||||
"asyncpg>=0.30.0",
|
||||
"alembic>=1.14.0",
|
||||
"redis>=5.2.0",
|
||||
"httpx>=0.28.0",
|
||||
"websockets>=14.0",
|
||||
"web3>=7.6.0",
|
||||
"eth-account>=0.13.0",
|
||||
"python-jose[cryptography]>=3.3.0",
|
||||
"passlib[bcrypt]>=1.7.4",
|
||||
"python-multipart>=0.0.18",
|
||||
"orjson>=3.10.0",
|
||||
"structlog>=24.4.0",
|
||||
"tenacity>=9.0.0",
|
||||
"cachetools>=5.5.0",
|
||||
# DataStore (ClickHouse)
|
||||
"aiochclient>=2.6.0",
|
||||
# Message Queue (Redis-based, @hanzo/mq compatible)
|
||||
"arq>=0.26.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.3.0",
|
||||
"pytest-asyncio>=0.24.0",
|
||||
"pytest-cov>=6.0.0",
|
||||
"httpx>=0.28.0",
|
||||
"ruff>=0.8.0",
|
||||
"mypy>=1.13.0",
|
||||
"pre-commit>=4.0.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
bootnode = "bootnode.cli:main"
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://bootnode.dev"
|
||||
Documentation = "https://docs.bootnode.dev"
|
||||
Repository = "https://github.com/hanzoai/bootnode"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["bootnode"]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
line-length = 100
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"E", # pycodestyle errors
|
||||
"W", # pycodestyle warnings
|
||||
"F", # pyflakes
|
||||
"I", # isort
|
||||
"B", # flake8-bugbear
|
||||
"C4", # flake8-comprehensions
|
||||
"UP", # pyupgrade
|
||||
"ARG", # flake8-unused-arguments
|
||||
"SIM", # flake8-simplify
|
||||
]
|
||||
ignore = ["E501", "B008", "B904"]
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
known-first-party = ["bootnode"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
strict = true
|
||||
plugins = ["pydantic.mypy"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
addopts = "-v --tb=short"
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for Bootnode API."""
|
||||
@@ -0,0 +1,74 @@
|
||||
"""API tests."""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
|
||||
from bootnode.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client():
|
||||
"""Create test client."""
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://test"
|
||||
) as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_check(client: AsyncClient):
|
||||
"""Test health check endpoint."""
|
||||
response = await client.get("/health")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "healthy"
|
||||
assert "version" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_root(client: AsyncClient):
|
||||
"""Test root endpoint."""
|
||||
response = await client.get("/")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["name"] == "Bootnode API"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_chains(client: AsyncClient):
|
||||
"""Test chains list endpoint."""
|
||||
response = await client.get("/v1/chains")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "chains" in data
|
||||
assert "ethereum" in data["chains"]
|
||||
assert "polygon" in data["chains"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_chain(client: AsyncClient):
|
||||
"""Test get specific chain."""
|
||||
response = await client.get("/v1/chains/ethereum")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["name"] == "Ethereum"
|
||||
assert "mainnet" in data["networks"]
|
||||
assert "sepolia" in data["networks"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_chain_not_found(client: AsyncClient):
|
||||
"""Test get nonexistent chain."""
|
||||
response = await client.get("/v1/chains/notachain")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rpc_without_auth(client: AsyncClient):
|
||||
"""Test RPC endpoint without authentication."""
|
||||
response = await client.post(
|
||||
"/v1/rpc/ethereum/mainnet",
|
||||
json={"jsonrpc": "2.0", "method": "eth_blockNumber", "params": [], "id": 1}
|
||||
)
|
||||
assert response.status_code == 401
|
||||
Reference in New Issue
Block a user