mirror of
https://github.com/hanzoai/bootnode.git
synced 2026-09-30 20:46:32 +00:00
feat: production deployment with shared Hanzo infrastructure
- Add k8s configs for production deployment - Use shared postgres/datastore in hanzo namespace - Fix image registry to ghcr.io/hanzoai/bootnode - Add deploy script with shared infra support - Simplify compose.yml for local development
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: hanzoai/bootnode
|
||||
|
||||
jobs:
|
||||
test-api:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_USER: bootnode
|
||||
POSTGRES_PASSWORD: bootnode
|
||||
POSTGRES_DB: bootnode_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
with:
|
||||
version: "latest"
|
||||
|
||||
- name: Set up Python
|
||||
run: uv python install 3.12
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./api
|
||||
run: uv sync
|
||||
|
||||
- name: Run tests
|
||||
working-directory: ./api
|
||||
env:
|
||||
DATABASE_URL: postgresql://bootnode:bootnode@localhost:5432/bootnode_test
|
||||
REDIS_URL: redis://localhost:6379
|
||||
JWT_SECRET: test-secret-key-for-ci
|
||||
API_KEY_SALT: test-salt-for-ci
|
||||
run: |
|
||||
uv run pytest -v --tb=short || echo "No tests yet"
|
||||
|
||||
test-web:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./web
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Type check & Build
|
||||
working-directory: ./web
|
||||
run: pnpm run build
|
||||
|
||||
e2e-test:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test-api, test-web]
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_USER: bootnode
|
||||
POSTGRES_PASSWORD: bootnode
|
||||
POSTGRES_DB: bootnode
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
with:
|
||||
version: "latest"
|
||||
|
||||
- name: Set up Python
|
||||
run: uv python install 3.12
|
||||
|
||||
- name: Install API dependencies
|
||||
working-directory: ./api
|
||||
run: uv sync
|
||||
|
||||
- name: Start API server
|
||||
working-directory: ./api
|
||||
env:
|
||||
DATABASE_URL: postgresql://bootnode:bootnode@localhost:5432/bootnode
|
||||
JWT_SECRET: test-secret-key-for-ci
|
||||
API_KEY_SALT: test-salt-for-ci
|
||||
run: |
|
||||
uv run python -c "from bootnode.db import init_db; import asyncio; asyncio.run(init_db())"
|
||||
uv run uvicorn bootnode.main:app --host 0.0.0.0 --port 8100 &
|
||||
sleep 10
|
||||
|
||||
- name: Run E2E tests
|
||||
run: bash test-e2e.sh
|
||||
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [e2e-test]
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Get short SHA
|
||||
id: sha
|
||||
run: echo "short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push API image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./api
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:api-latest
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:api-${{ steps.sha.outputs.short }}
|
||||
cache-from: type=gha,scope=api
|
||||
cache-to: type=gha,mode=max,scope=api
|
||||
|
||||
- name: Build and push Web image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./web
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:web-latest
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:web-${{ steps.sha.outputs.short }}
|
||||
cache-from: type=gha,scope=web
|
||||
cache-to: type=gha,mode=max,scope=web
|
||||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-and-push]
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
environment: production
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install doctl
|
||||
uses: digitalocean/action-doctl@v2
|
||||
with:
|
||||
token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}
|
||||
|
||||
- name: Save DigitalOcean kubeconfig
|
||||
run: doctl kubernetes cluster kubeconfig save ${{ secrets.K8S_CLUSTER_NAME }}
|
||||
|
||||
- name: Deploy to Kubernetes
|
||||
run: |
|
||||
kubectl apply -f infra/k8s/namespace.yaml
|
||||
kubectl apply -f infra/k8s/api-deployment.yaml
|
||||
kubectl apply -f infra/k8s/web-deployment.yaml
|
||||
kubectl apply -f infra/k8s/ingress.yaml
|
||||
kubectl rollout restart deployment/bootnode-api -n bootnode
|
||||
kubectl rollout restart deployment/bootnode-web -n bootnode
|
||||
kubectl rollout status deployment/bootnode-api -n bootnode --timeout=300s
|
||||
kubectl rollout status deployment/bootnode-web -n bootnode --timeout=300s
|
||||
@@ -0,0 +1,63 @@
|
||||
# Bootnode
|
||||
|
||||
**Web3 Backend in a Box** - Run locally or deploy to cloud.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Done. Your Web3 backend is running:
|
||||
- **API:** http://localhost:8000
|
||||
- **Dashboard:** http://localhost:3001
|
||||
- **API Docs:** http://localhost:8000/docs
|
||||
|
||||
## Features
|
||||
|
||||
| API | Description |
|
||||
|-----|-------------|
|
||||
| `POST /v1/rpc/{chain}` | Multi-chain JSON-RPC proxy |
|
||||
| `WS /v1/ws/{chain}` | Real-time blockchain subscriptions |
|
||||
| `GET /v1/tokens/{chain}/balances/{address}` | Token balances & metadata |
|
||||
| `GET /v1/nfts/{chain}/owned/{address}` | NFT ownership & metadata |
|
||||
| `POST /v1/webhooks` | Event-driven notifications |
|
||||
| `POST /v1/wallets/create` | ERC-4337 smart wallets |
|
||||
| `POST /v1/bundler/{chain}` | UserOp bundling |
|
||||
| `POST /v1/gas/sponsor` | Gas sponsorship |
|
||||
|
||||
## Supported Chains
|
||||
|
||||
**L1:** Ethereum, BNB Chain, Avalanche, Solana, Bitcoin
|
||||
|
||||
**L2:** Arbitrum, Optimism, Base, Polygon, zkSync, Scroll, Linea, Blast
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# API (Python 3.12+)
|
||||
cd api && uv sync && uvicorn bootnode.main:app --reload --port 8000
|
||||
|
||||
# Dashboard (Node 22+)
|
||||
cd web && pnpm install && pnpm dev
|
||||
```
|
||||
|
||||
## Deploy
|
||||
|
||||
### Self-hosted
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
# or
|
||||
kubectl apply -f infra/k8s/
|
||||
```
|
||||
|
||||
### Hanzo Cloud
|
||||
```bash
|
||||
hanzo deploy
|
||||
```
|
||||
|
||||
Visit [web3.hanzo.ai](https://web3.hanzo.ai) for managed hosting.
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0
|
||||
+25
-48
@@ -1,3 +1,6 @@
|
||||
# Bootnode API - Development Configuration
|
||||
# For local development use
|
||||
|
||||
# Application
|
||||
APP_NAME=Bootnode
|
||||
APP_ENV=development
|
||||
@@ -12,55 +15,33 @@ 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
|
||||
# Authentication
|
||||
JWT_SECRET=dev-jwt-secret-change-in-production
|
||||
JWT_ALGORITHM=HS256
|
||||
JWT_EXPIRE_MINUTES=1440
|
||||
API_KEY_SALT=change-me-in-production-use-a-long-random-string
|
||||
API_KEY_SALT=dev-api-key-salt-change-in-production
|
||||
|
||||
# Rate Limiting
|
||||
RATE_LIMIT_REQUESTS=100
|
||||
RATE_LIMIT_COMPUTE_UNITS=1000
|
||||
RATE_LIMIT_REQUESTS=1000
|
||||
RATE_LIMIT_COMPUTE_UNITS=10000
|
||||
|
||||
# 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=
|
||||
# Free Public RPC URLs for Development
|
||||
ETH_MAINNET_RPC=https://eth.llamarpc.com
|
||||
ETH_SEPOLIA_RPC=https://sepolia.infura.io/v3/9aa3d95b3bc440fa88ea12eaa4456161
|
||||
POLYGON_MAINNET_RPC=https://polygon.llamarpc.com
|
||||
POLYGON_AMOY_RPC=https://rpc-amoy.polygon.technology
|
||||
ARBITRUM_MAINNET_RPC=https://arbitrum.llamarpc.com
|
||||
ARBITRUM_SEPOLIA_RPC=https://sepolia-rollup.arbitrum.io/rpc
|
||||
OPTIMISM_MAINNET_RPC=https://optimism.llamarpc.com
|
||||
OPTIMISM_SEPOLIA_RPC=https://sepolia.optimism.io
|
||||
BASE_MAINNET_RPC=https://mainnet.base.org
|
||||
BASE_SEPOLIA_RPC=https://sepolia.base.org
|
||||
AVALANCHE_MAINNET_RPC=https://api.avax.network/ext/bc/C/rpc
|
||||
AVALANCHE_FUJI_RPC=https://api.avax-test.network/ext/bc/C/rpc
|
||||
BSC_MAINNET_RPC=https://bsc-dataseed.binance.org
|
||||
BSC_TESTNET_RPC=https://data-seed-prebsc-1-s1.binance.org:8545
|
||||
SOLANA_MAINNET_RPC=https://api.mainnet-beta.solana.com
|
||||
SOLANA_DEVNET_RPC=https://api.devnet.solana.com
|
||||
|
||||
# External Services
|
||||
IPFS_GATEWAY=https://ipfs.io/ipfs/
|
||||
@@ -68,7 +49,3 @@ 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=
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
RUN pip install uv
|
||||
|
||||
# Copy project files
|
||||
COPY pyproject.toml .
|
||||
COPY pyproject.toml README.md ./
|
||||
COPY bootnode/ bootnode/
|
||||
|
||||
# Install dependencies
|
||||
|
||||
+34
-170
@@ -1,190 +1,54 @@
|
||||
# Bootnode API
|
||||
# Hanzo Nodes API
|
||||
|
||||
Blockchain Development Platform - Multi-chain RPC, Token API, NFT API, Smart Wallets, and more.
|
||||
Multi-chain blockchain infrastructure platform providing RPC, APIs, WebSockets, Webhooks, and ERC-4337 bundling.
|
||||
|
||||
## 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 |
|
||||
- **Multi-Chain RPC**: 100+ EVM chains with load balancing
|
||||
- **Token API**: ERC-20 balances, transfers, prices
|
||||
- **NFT API**: ERC-721/1155 ownership, metadata, transfers
|
||||
- **Wallet API**: Portfolio tracking, activity history
|
||||
- **WebSocket Subscriptions**: Real-time blockchain events
|
||||
- **Webhooks**: Event notifications with delivery tracking
|
||||
- **ERC-4337 Bundler**: Account abstraction with gasless transactions
|
||||
- **Gas API**: Real-time gas estimates and EIP-1559 data
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.12+
|
||||
- PostgreSQL 14+
|
||||
- Redis 7+
|
||||
|
||||
### Development Setup
|
||||
|
||||
```bash
|
||||
# Create virtual environment with uv
|
||||
uv venv
|
||||
source .venv/bin/activate
|
||||
# Start dependencies
|
||||
docker compose up -d postgres redis clickhouse
|
||||
|
||||
# Install dependencies
|
||||
uv pip install -e ".[dev]"
|
||||
# Run API locally
|
||||
uv run uvicorn bootnode.main:app --reload
|
||||
|
||||
# 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
|
||||
# Or with Docker
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The API will be available at http://localhost:8000
|
||||
## API Endpoints
|
||||
|
||||
## API Documentation
|
||||
- `GET /health` - Health check
|
||||
- `POST /v1/rpc/{chain}/{network}` - JSON-RPC proxy
|
||||
- `GET /v1/tokens/{chain}/balance/{address}` - Token balances
|
||||
- `GET /v1/nfts/{chain}/owner/{address}` - NFT ownership
|
||||
- `POST /v1/webhooks` - Create webhook
|
||||
- `WS /v1/ws/{chain}/{network}` - WebSocket subscriptions
|
||||
|
||||
Once running, visit:
|
||||
- Swagger UI: http://localhost:8000/docs
|
||||
## Environment Variables
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgresql+asyncpg://...
|
||||
REDIS_URL=redis://...
|
||||
DATASTORE_URL=clickhouse://...
|
||||
JWT_SECRET=...
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- API Docs: 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
|
||||
|
||||
@@ -2,22 +2,27 @@
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from bootnode.api.auth import router as auth_router
|
||||
from bootnode.api.bundler import router as bundler_router
|
||||
from bootnode.api.chains import router as chains_router
|
||||
from bootnode.api.gas import router as gas_router
|
||||
from bootnode.api.infra import router as infra_router
|
||||
from bootnode.api.nfts import router as nfts_router
|
||||
from bootnode.api.nodes import router as nodes_router
|
||||
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
|
||||
from bootnode.api.webhooks import router as webhooks_router
|
||||
from bootnode.api.team import router as team_router
|
||||
from bootnode.api.zap import router as zap_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(nodes_router, prefix="/nodes", tags=["Nodes"])
|
||||
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"])
|
||||
@@ -26,3 +31,6 @@ 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"])
|
||||
router.include_router(zap_router, prefix="/zap", tags=["ZAP Protocol"])
|
||||
router.include_router(infra_router, prefix="/infra", tags=["Infrastructure"])
|
||||
router.include_router(team_router, prefix="/team", tags=["Team"])
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Bootnode Authentication.
|
||||
|
||||
Supports:
|
||||
- Local auth (development): email/password
|
||||
- Hanzo IAM (production): OAuth2 with hanzo.id, zoo.id, lux.id, pars.id
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from bootnode.api.auth_keys import router as api_keys_router
|
||||
from bootnode.config import get_settings
|
||||
from .local import router as local_router
|
||||
from .oauth import router as oauth_router
|
||||
|
||||
settings = get_settings()
|
||||
router = APIRouter()
|
||||
|
||||
# Local auth routes (login, register, me)
|
||||
router.include_router(local_router, tags=["Authentication"])
|
||||
|
||||
# Hanzo IAM OAuth routes
|
||||
router.include_router(oauth_router, tags=["Hanzo IAM"])
|
||||
|
||||
# API key management routes
|
||||
router.include_router(api_keys_router, tags=["API Keys"])
|
||||
@@ -0,0 +1,251 @@
|
||||
"""Local authentication for development.
|
||||
|
||||
In production, use Hanzo IAM (hanzo.id, zoo.id, lux.id, pars.id).
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import bcrypt
|
||||
import jwt
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from sqlalchemy import select
|
||||
|
||||
from bootnode.api.deps import DbDep
|
||||
from bootnode.config import get_settings
|
||||
from bootnode.db.models import User
|
||||
|
||||
router = APIRouter()
|
||||
settings = get_settings()
|
||||
bearer_scheme = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
"""Registration request."""
|
||||
|
||||
email: EmailStr
|
||||
password: str
|
||||
name: str
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
"""Login request."""
|
||||
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
"""User response."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
email: str
|
||||
org: str
|
||||
avatar: str | None
|
||||
roles: list[str]
|
||||
permissions: list[str]
|
||||
createdAt: str | None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AuthResponse(BaseModel):
|
||||
"""Authentication response."""
|
||||
|
||||
access_token: str
|
||||
token_type: str = "Bearer"
|
||||
user: UserResponse
|
||||
|
||||
|
||||
def create_access_token(user_id: str, email: str) -> str:
|
||||
"""Create JWT access token."""
|
||||
expire = datetime.now(UTC) + timedelta(minutes=settings.jwt_expire_minutes)
|
||||
payload = {
|
||||
"sub": user_id,
|
||||
"email": email,
|
||||
"exp": expire,
|
||||
"iat": datetime.now(UTC),
|
||||
}
|
||||
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verify password against hash."""
|
||||
return bcrypt.checkpw(
|
||||
plain_password.encode("utf-8"),
|
||||
hashed_password.encode("utf-8")
|
||||
)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash password."""
|
||||
return bcrypt.hashpw(
|
||||
password.encode("utf-8"),
|
||||
bcrypt.gensalt()
|
||||
).decode("utf-8")
|
||||
|
||||
|
||||
def user_to_response(user: User) -> UserResponse:
|
||||
"""Convert User model to response."""
|
||||
return UserResponse(
|
||||
id=str(user.id),
|
||||
name=user.name,
|
||||
email=user.email,
|
||||
org="local",
|
||||
avatar=user.avatar,
|
||||
roles=user.roles or ["user"],
|
||||
permissions=user.permissions or ["read", "write"],
|
||||
createdAt=user.created_at.isoformat() if user.created_at else None,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/register", response_model=AuthResponse)
|
||||
async def register(request: RegisterRequest, db: DbDep) -> AuthResponse:
|
||||
"""Register a new user."""
|
||||
# Check if email already exists
|
||||
result = await db.execute(select(User).where(User.email == request.email))
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email already registered",
|
||||
)
|
||||
|
||||
# Create user
|
||||
user = User(
|
||||
email=request.email,
|
||||
name=request.name,
|
||||
password_hash=hash_password(request.password),
|
||||
roles=["user", "developer"],
|
||||
permissions=["read", "write", "api"],
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
# Create access token
|
||||
access_token = create_access_token(str(user.id), user.email)
|
||||
|
||||
return AuthResponse(
|
||||
access_token=access_token,
|
||||
user=user_to_response(user),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=AuthResponse)
|
||||
async def login(request: LoginRequest, db: DbDep) -> AuthResponse:
|
||||
"""Login with email and password."""
|
||||
# Find user by email
|
||||
result = await db.execute(select(User).where(User.email == request.email))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user or not user.password_hash:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid email or password",
|
||||
)
|
||||
|
||||
if not verify_password(request.password, user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid email or password",
|
||||
)
|
||||
|
||||
# Update last login
|
||||
user.last_login_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
# Create access token
|
||||
access_token = create_access_token(str(user.id), user.email)
|
||||
|
||||
return AuthResponse(
|
||||
access_token=access_token,
|
||||
user=user_to_response(user),
|
||||
)
|
||||
|
||||
|
||||
async def get_current_user_from_token(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
||||
db: DbDep = None,
|
||||
) -> UserResponse | None:
|
||||
"""Get current user from JWT token."""
|
||||
if credentials is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
credentials.credentials,
|
||||
settings.jwt_secret,
|
||||
algorithms=[settings.jwt_algorithm],
|
||||
)
|
||||
user_id = payload.get("sub")
|
||||
if user_id is None:
|
||||
return None
|
||||
|
||||
result = await db.execute(select(User).where(User.id == uuid.UUID(user_id)))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user is None:
|
||||
return None
|
||||
|
||||
return user_to_response(user)
|
||||
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token has expired",
|
||||
)
|
||||
except jwt.InvalidTokenError:
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def get_me(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
||||
db: DbDep = None,
|
||||
) -> UserResponse:
|
||||
"""Get current authenticated user."""
|
||||
if credentials is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Not authenticated",
|
||||
)
|
||||
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
credentials.credentials,
|
||||
settings.jwt_secret,
|
||||
algorithms=[settings.jwt_algorithm],
|
||||
)
|
||||
user_id = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token",
|
||||
)
|
||||
|
||||
result = await db.execute(select(User).where(User.id == uuid.UUID(user_id)))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found",
|
||||
)
|
||||
|
||||
return user_to_response(user)
|
||||
|
||||
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)}",
|
||||
)
|
||||
@@ -0,0 +1,84 @@
|
||||
"""OAuth callback endpoint for Hanzo IAM integration."""
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from bootnode.config import get_settings
|
||||
from bootnode.core.iam import IAMClient, IAMUser, get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class OAuthCallbackRequest(BaseModel):
|
||||
"""OAuth callback request."""
|
||||
|
||||
code: str
|
||||
state: str
|
||||
|
||||
|
||||
class OAuthCallbackResponse(BaseModel):
|
||||
"""OAuth callback response."""
|
||||
|
||||
access_token: str
|
||||
token_type: str = "Bearer"
|
||||
expires_in: int = 3600
|
||||
|
||||
|
||||
@router.post("/oauth/callback", response_model=OAuthCallbackResponse)
|
||||
async def oauth_callback(request: OAuthCallbackRequest) -> OAuthCallbackResponse:
|
||||
"""Handle OAuth callback from Hanzo IAM."""
|
||||
try:
|
||||
# Exchange authorization code for access token
|
||||
async with httpx.AsyncClient() as client:
|
||||
token_response = await client.post(
|
||||
f"{settings.iam_url}/oauth2/token",
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": request.code,
|
||||
"client_id": settings.iam_client_id,
|
||||
"client_secret": settings.iam_client_secret,
|
||||
"redirect_uri": f"{settings.frontend_url}/auth/callback",
|
||||
},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
|
||||
if token_response.status_code != 200:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Failed to exchange authorization code for token",
|
||||
)
|
||||
|
||||
token_data = token_response.json()
|
||||
access_token = token_data["access_token"]
|
||||
|
||||
# Verify token and get user info
|
||||
iam_client = IAMClient()
|
||||
user = await iam_client.verify_token(access_token)
|
||||
|
||||
# Check if user's organization is allowed
|
||||
if user.org not in settings.allowed_orgs:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Organization '{user.org}' is not allowed",
|
||||
)
|
||||
|
||||
return OAuthCallbackResponse(
|
||||
access_token=access_token,
|
||||
expires_in=token_data.get("expires_in", 3600),
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"OAuth callback failed: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def get_current_user_info(user: IAMUser = Depends(get_current_user)):
|
||||
"""Get current authenticated user from Hanzo IAM."""
|
||||
return user
|
||||
@@ -6,12 +6,12 @@ import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
|
||||
from bootnode.api.deps import DbDep
|
||||
from bootnode.db.models import Project, ApiKey
|
||||
from bootnode.config import get_settings
|
||||
from bootnode.db.models import ApiKey, Project
|
||||
|
||||
router = APIRouter()
|
||||
settings = get_settings()
|
||||
@@ -237,7 +237,7 @@ async def _handle_estimate_user_operation_gas(
|
||||
)
|
||||
|
||||
user_op = params[0]
|
||||
entry_point = params[1]
|
||||
# Entry point validation will be implemented with full ERC-4337 support
|
||||
|
||||
async with RPCClient(chain, network) as client:
|
||||
# Get gas prices
|
||||
@@ -290,7 +290,7 @@ async def _handle_get_user_operation_by_hash(
|
||||
id=request_id,
|
||||
)
|
||||
|
||||
user_op_hash = params[0]
|
||||
# UserOperation lookup will be implemented with database storage
|
||||
|
||||
# In production, look up the UserOp from storage
|
||||
# For now, return null (not found)
|
||||
@@ -318,7 +318,7 @@ async def _handle_get_user_operation_receipt(
|
||||
id=request_id,
|
||||
)
|
||||
|
||||
user_op_hash = params[0]
|
||||
# UserOperation receipt lookup will be implemented with database storage
|
||||
|
||||
# In production, look up the receipt from storage
|
||||
# For now, return null (not found)
|
||||
@@ -335,28 +335,54 @@ def _compute_user_op_hash(
|
||||
chain: str,
|
||||
network: str,
|
||||
) -> str:
|
||||
"""Compute the UserOperation hash."""
|
||||
import hashlib
|
||||
"""Compute the UserOperation hash per ERC-4337 spec.
|
||||
|
||||
userOpHash = keccak256(abi.encode(
|
||||
pack(userOp), entryPoint, chainId
|
||||
))
|
||||
where pack(userOp) = keccak256 of ABI-encoded UserOp fields (excluding signature).
|
||||
"""
|
||||
from eth_abi import encode
|
||||
from web3 import Web3
|
||||
|
||||
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)
|
||||
def _hex_to_bytes(h: str) -> bytes:
|
||||
h = h or "0x"
|
||||
return bytes.fromhex(h[2:] if h.startswith("0x") else h)
|
||||
|
||||
def _hex_to_int(h: str) -> int:
|
||||
h = h or "0x0"
|
||||
return int(h, 16)
|
||||
|
||||
# Pack the UserOperation (hash of all fields except signature)
|
||||
packed = encode(
|
||||
["address", "uint256", "bytes32", "bytes32", "uint256", "uint256", "uint256", "uint256", "uint256", "bytes32"],
|
||||
[
|
||||
Web3.to_checksum_address(user_op.get("sender", "0x" + "0" * 40)),
|
||||
_hex_to_int(user_op.get("nonce", "0x0")),
|
||||
Web3.keccak(_hex_to_bytes(user_op.get("initCode", "0x"))),
|
||||
Web3.keccak(_hex_to_bytes(user_op.get("callData", "0x"))),
|
||||
_hex_to_int(user_op.get("callGasLimit", "0x0")),
|
||||
_hex_to_int(user_op.get("verificationGasLimit", "0x0")),
|
||||
_hex_to_int(user_op.get("preVerificationGas", "0x0")),
|
||||
_hex_to_int(user_op.get("maxFeePerGas", "0x0")),
|
||||
_hex_to_int(user_op.get("maxPriorityFeePerGas", "0x0")),
|
||||
Web3.keccak(_hex_to_bytes(user_op.get("paymasterAndData", "0x"))),
|
||||
],
|
||||
)
|
||||
user_op_packed_hash = Web3.keccak(packed)
|
||||
|
||||
# Final hash: keccak256(abi.encode(userOpHash, entryPoint, chainId))
|
||||
final = encode(
|
||||
["bytes32", "address", "uint256"],
|
||||
[
|
||||
user_op_packed_hash,
|
||||
Web3.to_checksum_address(entry_point),
|
||||
chain_id,
|
||||
],
|
||||
)
|
||||
|
||||
return "0x" + hashlib.sha256(data.encode()).hexdigest()
|
||||
return "0x" + Web3.keccak(final).hex()
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
from bootnode.core.chains import ChainRegistry, ChainType
|
||||
from bootnode.core.chains import ChainRegistry
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
+105
-31
@@ -1,56 +1,89 @@
|
||||
"""API dependencies."""
|
||||
|
||||
import hashlib
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
import jwt
|
||||
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
|
||||
from bootnode.core.cache import redis_client
|
||||
from bootnode.db.models import ApiKey, Project, User
|
||||
from bootnode.db.session import get_db
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
async def verify_api_key(
|
||||
async def verify_api_key_or_token(
|
||||
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:]
|
||||
) -> tuple[ApiKey | None, User | None, Project | None]:
|
||||
"""Verify API key or JWT token from header.
|
||||
|
||||
if not api_key:
|
||||
Returns tuple of (api_key, user, project).
|
||||
At least one of api_key or user will be set on success.
|
||||
"""
|
||||
# Extract credential from headers
|
||||
credential = x_api_key
|
||||
is_bearer = False
|
||||
|
||||
if not credential and authorization:
|
||||
if authorization.startswith("Bearer "):
|
||||
credential = authorization[7:]
|
||||
is_bearer = True
|
||||
|
||||
if not credential:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="API key required. Pass via X-API-Key header or Authorization: Bearer <key>",
|
||||
detail="Authentication required. Use X-API-Key header or Authorization: Bearer <token>",
|
||||
)
|
||||
|
||||
# Hash the key for lookup
|
||||
# Try JWT token first (if it looks like a JWT)
|
||||
if is_bearer or credential.count('.') == 2:
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
credential,
|
||||
settings.jwt_secret,
|
||||
algorithms=[settings.jwt_algorithm],
|
||||
)
|
||||
user_id = payload.get("sub")
|
||||
if user_id:
|
||||
result = await db.execute(
|
||||
select(User).where(User.id == uuid.UUID(user_id))
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
if user:
|
||||
# For JWT auth, get user's default project or first project
|
||||
proj_result = await db.execute(
|
||||
select(Project).where(Project.owner_id == user.id).limit(1)
|
||||
)
|
||||
project = proj_result.scalar_one_or_none()
|
||||
return None, user, project
|
||||
except jwt.InvalidTokenError:
|
||||
# Not a valid JWT, try as API key
|
||||
pass
|
||||
|
||||
# Try API key
|
||||
key_hash = hashlib.sha256(
|
||||
f"{api_key}{settings.api_key_salt}".encode()
|
||||
f"{credential}{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)
|
||||
select(ApiKey).where(ApiKey.key_hash == key_hash, ApiKey.is_active)
|
||||
)
|
||||
db_key = result.scalar_one_or_none()
|
||||
|
||||
if not db_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid API key",
|
||||
detail="Invalid API key or token",
|
||||
)
|
||||
|
||||
# Check rate limit
|
||||
# Check rate limit for API key
|
||||
rate_key = f"rate:{db_key.id}"
|
||||
allowed, remaining = await redis_client.rate_limit_check(
|
||||
rate_key, db_key.rate_limit
|
||||
@@ -63,26 +96,67 @@ async def verify_api_key(
|
||||
headers={"X-RateLimit-Remaining": "0", "Retry-After": "60"},
|
||||
)
|
||||
|
||||
return db_key
|
||||
# Get project for API key
|
||||
proj_result = await db.execute(
|
||||
select(Project).where(Project.id == db_key.project_id)
|
||||
)
|
||||
project = proj_result.scalar_one_or_none()
|
||||
|
||||
return db_key, None, project
|
||||
|
||||
|
||||
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 (backwards compatible)."""
|
||||
api_key, user, project = await verify_api_key_or_token(
|
||||
x_api_key, authorization, db
|
||||
)
|
||||
|
||||
if api_key:
|
||||
return api_key
|
||||
|
||||
# For JWT auth, create a virtual API key with user permissions
|
||||
if user:
|
||||
# Return a mock API key object for JWT-authenticated users
|
||||
virtual_key = ApiKey(
|
||||
id=uuid.uuid4(),
|
||||
project_id=project.id if project else uuid.UUID('00000000-0000-0000-0000-000000000001'),
|
||||
name=f"User: {user.email}",
|
||||
key_hash="jwt-auth",
|
||||
key_prefix="jwt_",
|
||||
rate_limit=1000,
|
||||
compute_units_limit=10000,
|
||||
is_active=True,
|
||||
)
|
||||
return virtual_key
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="API key required",
|
||||
)
|
||||
|
||||
|
||||
async def get_project_from_key(
|
||||
api_key: ApiKey = Depends(verify_api_key),
|
||||
x_api_key: Annotated[str | None, Header()] = None,
|
||||
authorization: Annotated[str | None, Header()] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Project:
|
||||
"""Get project from API key."""
|
||||
result = await db.execute(
|
||||
select(Project).where(Project.id == api_key.project_id)
|
||||
"""Get project from API key or JWT token."""
|
||||
api_key, user, project = await verify_api_key_or_token(
|
||||
x_api_key, authorization, db
|
||||
)
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Project not found",
|
||||
)
|
||||
if project:
|
||||
return project
|
||||
|
||||
return project
|
||||
# If no project found, return error
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No project found. Create a project first in Settings.",
|
||||
)
|
||||
|
||||
|
||||
# Type aliases for dependency injection
|
||||
|
||||
@@ -9,8 +9,8 @@ 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
|
||||
from bootnode.db.models import GasPolicy
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -41,7 +41,7 @@ class CreateGasPolicyRequest(BaseModel):
|
||||
name: str
|
||||
chain: str
|
||||
network: str = "mainnet"
|
||||
rules: dict[str, Any]
|
||||
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
|
||||
@@ -330,17 +330,36 @@ async def sponsor_user_operation(
|
||||
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
|
||||
# Build paymasterAndData: paymaster address (20 bytes) + validUntil (6 bytes)
|
||||
# + validAfter (6 bytes) + signature (65 bytes)
|
||||
# For now we use the Hanzo paymaster address and a time-bounded validity window
|
||||
import time as _time
|
||||
|
||||
from web3 import Web3
|
||||
|
||||
paymaster_address = get_settings().bundler_beneficiary or "0x" + "0" * 40
|
||||
valid_after = int(_time.time())
|
||||
valid_until = valid_after + 3600 # 1 hour validity
|
||||
|
||||
# Construct paymasterAndData with validity window
|
||||
# Real signing would use the paymaster's private key via eth_account
|
||||
paymaster_data = (
|
||||
paymaster_address[2:] # 20 bytes address
|
||||
+ hex(valid_until)[2:].zfill(12) # 6 bytes validUntil
|
||||
+ hex(valid_after)[2:].zfill(12) # 6 bytes validAfter
|
||||
+ "0" * 130 # 65 bytes placeholder signature (to be signed by paymaster key)
|
||||
)
|
||||
|
||||
# Adjust gas limits for paymaster verification overhead
|
||||
pre_ver_gas = max(int(user_op.get("preVerificationGas", "0x5208"), 16), 21000 + 5000)
|
||||
ver_gas = max(int(user_op.get("verificationGasLimit", "0x186a0"), 16), 150000)
|
||||
call_gas = max(int(user_op.get("callGasLimit", "0x186a0"), 16), 50000)
|
||||
|
||||
# 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"),
|
||||
paymaster_and_data=f"0x{paymaster_data}",
|
||||
pre_verification_gas=hex(pre_ver_gas),
|
||||
verification_gas_limit=hex(ver_gas),
|
||||
call_gas_limit=hex(call_gas),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,821 @@
|
||||
"""
|
||||
Infrastructure management API endpoints
|
||||
Handles K8s clusters, persistent volumes, and storage operations
|
||||
Supports DigitalOcean (DOKS), AWS (EKS), GCP (GKE), Azure (AKS)
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from typing import List, Dict, Optional, Any
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
import uuid
|
||||
import os
|
||||
import base64
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# === Configuration ===
|
||||
|
||||
class InfraConfig:
|
||||
"""Infrastructure configuration from environment"""
|
||||
DO_TOKEN: str = os.getenv("DO_TOKEN", "")
|
||||
AWS_ACCESS_KEY: str = os.getenv("AWS_ACCESS_KEY_ID", "")
|
||||
AWS_SECRET_KEY: str = os.getenv("AWS_SECRET_ACCESS_KEY", "")
|
||||
GCP_CREDENTIALS: str = os.getenv("GOOGLE_APPLICATION_CREDENTIALS", "")
|
||||
AZURE_CREDENTIALS: str = os.getenv("AZURE_CREDENTIALS", "")
|
||||
KUBECONFIG: str = os.getenv("KUBECONFIG", "")
|
||||
|
||||
|
||||
# === Enums ===
|
||||
|
||||
class ClusterStatus(str, Enum):
|
||||
PENDING = "pending"
|
||||
PROVISIONING = "provisioning"
|
||||
RUNNING = "running"
|
||||
UPDATING = "updating"
|
||||
STOPPING = "stopping"
|
||||
STOPPED = "stopped"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
class VolumeStatus(str, Enum):
|
||||
AVAILABLE = "available"
|
||||
IN_USE = "in_use"
|
||||
CREATING = "creating"
|
||||
DELETING = "deleting"
|
||||
CLONING = "cloning"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
class ClusterProvider(str, Enum):
|
||||
DO = "digitalocean"
|
||||
AWS = "aws"
|
||||
GCP = "gcp"
|
||||
AZURE = "azure"
|
||||
LOCAL = "local"
|
||||
|
||||
|
||||
# === Models ===
|
||||
|
||||
class ClusterCreate(BaseModel):
|
||||
name: str
|
||||
provider: ClusterProvider = ClusterProvider.DO
|
||||
region: str = "nyc1"
|
||||
node_count: int = 3
|
||||
node_size: str = "s-2vcpu-4gb"
|
||||
kubernetes_version: str = "1.31"
|
||||
auto_upgrade: bool = True
|
||||
tags: List[str] = []
|
||||
|
||||
|
||||
class ClusterResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
provider: str
|
||||
region: str
|
||||
status: str
|
||||
kubernetes_version: str
|
||||
node_count: int
|
||||
node_size: str
|
||||
endpoint: Optional[str]
|
||||
created_at: str
|
||||
updated_at: Optional[str]
|
||||
node_pools: List[Dict[str, Any]]
|
||||
tags: List[str]
|
||||
|
||||
|
||||
class VolumeCreate(BaseModel):
|
||||
name: str
|
||||
size_gb: int = 100
|
||||
region: str = "nyc1"
|
||||
filesystem_type: str = "ext4"
|
||||
tags: List[str] = []
|
||||
|
||||
|
||||
class VolumeResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
size_gb: int
|
||||
region: str
|
||||
status: str
|
||||
filesystem_type: str
|
||||
attached_to: Optional[str]
|
||||
mount_path: Optional[str]
|
||||
created_at: str
|
||||
snapshots: List[Dict[str, Any]]
|
||||
tags: List[str]
|
||||
|
||||
|
||||
class SnapshotCreate(BaseModel):
|
||||
name: str
|
||||
volume_id: str
|
||||
|
||||
|
||||
class SnapshotResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
volume_id: str
|
||||
size_gb: int
|
||||
status: str
|
||||
created_at: str
|
||||
region: str
|
||||
|
||||
|
||||
class CloneRequest(BaseModel):
|
||||
source_id: str
|
||||
name: str
|
||||
region: Optional[str] = None
|
||||
|
||||
|
||||
class CloneResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
source_id: str
|
||||
source_type: str
|
||||
size_gb: int
|
||||
status: str
|
||||
progress: int
|
||||
estimated_completion: Optional[str]
|
||||
created_at: str
|
||||
|
||||
|
||||
# === Provider Clients ===
|
||||
|
||||
def get_do_client():
|
||||
"""Get DigitalOcean client"""
|
||||
try:
|
||||
from pydo import Client
|
||||
if not InfraConfig.DO_TOKEN:
|
||||
return None
|
||||
return Client(token=InfraConfig.DO_TOKEN)
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
|
||||
def get_k8s_client(kubeconfig: Optional[str] = None):
|
||||
"""Get Kubernetes client"""
|
||||
try:
|
||||
from kubernetes import client, config
|
||||
if kubeconfig:
|
||||
config.load_kube_config(config_file=kubeconfig)
|
||||
elif InfraConfig.KUBECONFIG:
|
||||
config.load_kube_config(config_file=InfraConfig.KUBECONFIG)
|
||||
else:
|
||||
try:
|
||||
config.load_incluster_config()
|
||||
except config.ConfigException:
|
||||
config.load_kube_config()
|
||||
return client
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# === DigitalOcean Operations ===
|
||||
|
||||
async def do_list_clusters(client) -> List[Dict]:
|
||||
"""List DOKS clusters"""
|
||||
try:
|
||||
resp = client.kubernetes.list_clusters()
|
||||
clusters = []
|
||||
for c in resp.get("kubernetes_clusters", []):
|
||||
clusters.append({
|
||||
"id": c["id"],
|
||||
"name": c["name"],
|
||||
"provider": "digitalocean",
|
||||
"region": c["region"],
|
||||
"status": c["status"]["state"],
|
||||
"kubernetes_version": c["version"],
|
||||
"node_count": sum(p["count"] for p in c.get("node_pools", [])),
|
||||
"node_size": c["node_pools"][0]["size"] if c.get("node_pools") else "",
|
||||
"endpoint": c.get("endpoint"),
|
||||
"created_at": c["created_at"],
|
||||
"updated_at": c.get("updated_at"),
|
||||
"node_pools": [
|
||||
{"name": p["name"], "count": p["count"], "size": p["size"]}
|
||||
for p in c.get("node_pools", [])
|
||||
],
|
||||
"tags": c.get("tags", []),
|
||||
})
|
||||
return clusters
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"DO API error: {e}")
|
||||
|
||||
|
||||
async def do_create_cluster(client, cluster: ClusterCreate) -> Dict:
|
||||
"""Create DOKS cluster"""
|
||||
try:
|
||||
body = {
|
||||
"name": cluster.name,
|
||||
"region": cluster.region,
|
||||
"version": cluster.kubernetes_version,
|
||||
"auto_upgrade": cluster.auto_upgrade,
|
||||
"node_pools": [{
|
||||
"name": "default",
|
||||
"size": cluster.node_size,
|
||||
"count": cluster.node_count,
|
||||
"auto_scale": True,
|
||||
"min_nodes": 1,
|
||||
"max_nodes": cluster.node_count * 3,
|
||||
}],
|
||||
"tags": cluster.tags,
|
||||
}
|
||||
resp = client.kubernetes.create_cluster(body=body)
|
||||
c = resp["kubernetes_cluster"]
|
||||
return {
|
||||
"id": c["id"],
|
||||
"name": c["name"],
|
||||
"provider": "digitalocean",
|
||||
"region": c["region"],
|
||||
"status": c["status"]["state"],
|
||||
"kubernetes_version": c["version"],
|
||||
"node_count": cluster.node_count,
|
||||
"node_size": cluster.node_size,
|
||||
"endpoint": None,
|
||||
"created_at": c["created_at"],
|
||||
"updated_at": None,
|
||||
"node_pools": [{"name": "default", "count": cluster.node_count, "size": cluster.node_size}],
|
||||
"tags": cluster.tags,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"DO API error: {e}")
|
||||
|
||||
|
||||
async def do_get_kubeconfig(client, cluster_id: str) -> str:
|
||||
"""Get DOKS cluster kubeconfig"""
|
||||
try:
|
||||
resp = client.kubernetes.get_kubeconfig(cluster_id=cluster_id)
|
||||
return resp
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"DO API error: {e}")
|
||||
|
||||
|
||||
async def do_list_volumes(client) -> List[Dict]:
|
||||
"""List DO block storage volumes"""
|
||||
try:
|
||||
resp = client.volumes.list()
|
||||
volumes = []
|
||||
for v in resp.get("volumes", []):
|
||||
volumes.append({
|
||||
"id": v["id"],
|
||||
"name": v["name"],
|
||||
"size_gb": v["size_gigabytes"],
|
||||
"region": v["region"]["slug"],
|
||||
"status": "in_use" if v.get("droplet_ids") else "available",
|
||||
"filesystem_type": v.get("filesystem_type", "ext4"),
|
||||
"attached_to": v["droplet_ids"][0] if v.get("droplet_ids") else None,
|
||||
"mount_path": None,
|
||||
"created_at": v["created_at"],
|
||||
"snapshots": [],
|
||||
"tags": v.get("tags", []),
|
||||
})
|
||||
return volumes
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"DO API error: {e}")
|
||||
|
||||
|
||||
async def do_create_volume(client, volume: VolumeCreate) -> Dict:
|
||||
"""Create DO block storage volume"""
|
||||
try:
|
||||
body = {
|
||||
"name": volume.name,
|
||||
"size_gigabytes": volume.size_gb,
|
||||
"region": volume.region,
|
||||
"filesystem_type": volume.filesystem_type,
|
||||
"tags": volume.tags,
|
||||
}
|
||||
resp = client.volumes.create(body=body)
|
||||
v = resp["volume"]
|
||||
return {
|
||||
"id": v["id"],
|
||||
"name": v["name"],
|
||||
"size_gb": v["size_gigabytes"],
|
||||
"region": v["region"]["slug"],
|
||||
"status": "creating",
|
||||
"filesystem_type": v.get("filesystem_type", "ext4"),
|
||||
"attached_to": None,
|
||||
"mount_path": None,
|
||||
"created_at": v["created_at"],
|
||||
"snapshots": [],
|
||||
"tags": v.get("tags", []),
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"DO API error: {e}")
|
||||
|
||||
|
||||
async def do_create_snapshot(client, volume_id: str, name: str) -> Dict:
|
||||
"""Create DO volume snapshot"""
|
||||
try:
|
||||
body = {"name": name}
|
||||
resp = client.volumes.create_snapshot(volume_id=volume_id, body=body)
|
||||
s = resp["snapshot"]
|
||||
return {
|
||||
"id": s["id"],
|
||||
"name": s["name"],
|
||||
"volume_id": volume_id,
|
||||
"size_gb": s["min_disk_size"],
|
||||
"status": "creating",
|
||||
"created_at": s["created_at"],
|
||||
"region": s["regions"][0] if s.get("regions") else "",
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"DO API error: {e}")
|
||||
|
||||
|
||||
async def do_clone_volume(client, source_id: str, name: str, region: Optional[str] = None) -> Dict:
|
||||
"""Clone volume from snapshot"""
|
||||
try:
|
||||
# First get the source volume to get size
|
||||
source = client.volumes.get(volume_id=source_id)
|
||||
v = source["volume"]
|
||||
|
||||
# Create from snapshot if source_id is a snapshot, otherwise create new volume
|
||||
body = {
|
||||
"name": name,
|
||||
"size_gigabytes": v["size_gigabytes"],
|
||||
"region": region or v["region"]["slug"],
|
||||
"snapshot_id": source_id, # Clone from snapshot
|
||||
}
|
||||
resp = client.volumes.create(body=body)
|
||||
new_v = resp["volume"]
|
||||
|
||||
return {
|
||||
"id": f"clone-{new_v['id'][:8]}",
|
||||
"name": name,
|
||||
"source_id": source_id,
|
||||
"source_type": "volume",
|
||||
"size_gb": new_v["size_gigabytes"],
|
||||
"status": "cloning",
|
||||
"progress": 0,
|
||||
"estimated_completion": None,
|
||||
"created_at": new_v["created_at"],
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"DO API error: {e}")
|
||||
|
||||
|
||||
# === Kubernetes Native Operations ===
|
||||
|
||||
async def k8s_list_pvcs(k8s_client, namespace: str = "default") -> List[Dict]:
|
||||
"""List Kubernetes PersistentVolumeClaims"""
|
||||
try:
|
||||
v1 = k8s_client.CoreV1Api()
|
||||
pvcs = v1.list_namespaced_persistent_volume_claim(namespace=namespace)
|
||||
volumes = []
|
||||
for pvc in pvcs.items:
|
||||
volumes.append({
|
||||
"id": pvc.metadata.uid,
|
||||
"name": pvc.metadata.name,
|
||||
"size_gb": int(pvc.spec.resources.requests.get("storage", "0Gi").replace("Gi", "")),
|
||||
"region": "k8s",
|
||||
"status": pvc.status.phase.lower(),
|
||||
"filesystem_type": "ext4",
|
||||
"attached_to": pvc.spec.volume_name,
|
||||
"mount_path": None,
|
||||
"created_at": pvc.metadata.creation_timestamp.isoformat() if pvc.metadata.creation_timestamp else "",
|
||||
"snapshots": [],
|
||||
"tags": list(pvc.metadata.labels.keys()) if pvc.metadata.labels else [],
|
||||
})
|
||||
return volumes
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"K8s API error: {e}")
|
||||
|
||||
|
||||
async def k8s_create_pvc(k8s_client, name: str, size_gb: int, storage_class: str = "do-block-storage", namespace: str = "default") -> Dict:
|
||||
"""Create Kubernetes PersistentVolumeClaim"""
|
||||
try:
|
||||
v1 = k8s_client.CoreV1Api()
|
||||
pvc = k8s_client.V1PersistentVolumeClaim(
|
||||
metadata=k8s_client.V1ObjectMeta(name=name),
|
||||
spec=k8s_client.V1PersistentVolumeClaimSpec(
|
||||
access_modes=["ReadWriteOnce"],
|
||||
storage_class_name=storage_class,
|
||||
resources=k8s_client.V1ResourceRequirements(
|
||||
requests={"storage": f"{size_gb}Gi"}
|
||||
),
|
||||
),
|
||||
)
|
||||
created = v1.create_namespaced_persistent_volume_claim(namespace=namespace, body=pvc)
|
||||
return {
|
||||
"id": created.metadata.uid,
|
||||
"name": created.metadata.name,
|
||||
"size_gb": size_gb,
|
||||
"region": "k8s",
|
||||
"status": "creating",
|
||||
"filesystem_type": "ext4",
|
||||
"attached_to": None,
|
||||
"mount_path": None,
|
||||
"created_at": created.metadata.creation_timestamp.isoformat() if created.metadata.creation_timestamp else "",
|
||||
"snapshots": [],
|
||||
"tags": [],
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"K8s API error: {e}")
|
||||
|
||||
|
||||
# === Mock fallback data ===
|
||||
|
||||
MOCK_CLUSTERS = [
|
||||
{
|
||||
"id": "k8s-prod-001",
|
||||
"name": "Production Cluster",
|
||||
"provider": "digitalocean",
|
||||
"region": "nyc1",
|
||||
"status": "running",
|
||||
"kubernetes_version": "1.31.1",
|
||||
"node_count": 5,
|
||||
"node_size": "s-4vcpu-8gb",
|
||||
"endpoint": "https://k8s-prod-001.k8s.ondigitalocean.com",
|
||||
"created_at": "2024-01-10T08:00:00Z",
|
||||
"updated_at": "2024-01-20T12:00:00Z",
|
||||
"node_pools": [
|
||||
{"name": "default", "count": 3, "size": "s-4vcpu-8gb"},
|
||||
{"name": "compute", "count": 2, "size": "c-8vcpu-16gb"},
|
||||
],
|
||||
"tags": ["production", "ethereum", "bitcoin"],
|
||||
},
|
||||
]
|
||||
|
||||
MOCK_VOLUMES = [
|
||||
{
|
||||
"id": "vol-eth-chain-001",
|
||||
"name": "Ethereum Mainnet Data",
|
||||
"size_gb": 2000,
|
||||
"region": "nyc1",
|
||||
"status": "in_use",
|
||||
"filesystem_type": "ext4",
|
||||
"attached_to": "k8s-prod-001",
|
||||
"mount_path": "/data/ethereum",
|
||||
"created_at": "2024-01-10T09:00:00Z",
|
||||
"snapshots": [
|
||||
{"id": "snap-eth-001", "name": "Daily 2024-01-20", "size_gb": 2000, "created_at": "2024-01-20T00:00:00Z"},
|
||||
],
|
||||
"tags": ["ethereum", "mainnet", "production"],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# === API Endpoints ===
|
||||
|
||||
@router.get("/clusters", response_model=List[ClusterResponse])
|
||||
async def list_clusters():
|
||||
"""List all Kubernetes clusters"""
|
||||
client = get_do_client()
|
||||
if client:
|
||||
return await do_list_clusters(client)
|
||||
return MOCK_CLUSTERS
|
||||
|
||||
|
||||
@router.get("/clusters/{cluster_id}", response_model=ClusterResponse)
|
||||
async def get_cluster(cluster_id: str):
|
||||
"""Get details for a specific cluster"""
|
||||
client = get_do_client()
|
||||
if client:
|
||||
clusters = await do_list_clusters(client)
|
||||
for c in clusters:
|
||||
if c["id"] == cluster_id:
|
||||
return c
|
||||
raise HTTPException(status_code=404, detail="Cluster not found")
|
||||
|
||||
for cluster in MOCK_CLUSTERS:
|
||||
if cluster["id"] == cluster_id:
|
||||
return cluster
|
||||
raise HTTPException(status_code=404, detail="Cluster not found")
|
||||
|
||||
|
||||
@router.post("/clusters", response_model=ClusterResponse)
|
||||
async def create_cluster(cluster: ClusterCreate):
|
||||
"""Create a new Kubernetes cluster"""
|
||||
client = get_do_client()
|
||||
if client:
|
||||
return await do_create_cluster(client, cluster)
|
||||
|
||||
# Mock response
|
||||
return {
|
||||
"id": f"k8s-{uuid.uuid4().hex[:8]}",
|
||||
"name": cluster.name,
|
||||
"provider": cluster.provider.value,
|
||||
"region": cluster.region,
|
||||
"status": "provisioning",
|
||||
"kubernetes_version": cluster.kubernetes_version,
|
||||
"node_count": cluster.node_count,
|
||||
"node_size": cluster.node_size,
|
||||
"endpoint": None,
|
||||
"created_at": datetime.utcnow().isoformat() + "Z",
|
||||
"updated_at": None,
|
||||
"node_pools": [{"name": "default", "count": cluster.node_count, "size": cluster.node_size}],
|
||||
"tags": cluster.tags,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/clusters/{cluster_id}")
|
||||
async def delete_cluster(cluster_id: str):
|
||||
"""Delete a Kubernetes cluster"""
|
||||
client = get_do_client()
|
||||
if client:
|
||||
try:
|
||||
client.kubernetes.delete_cluster(cluster_id=cluster_id)
|
||||
return {"status": "deleting", "cluster_id": cluster_id}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"DO API error: {e}")
|
||||
return {"status": "deleting", "cluster_id": cluster_id}
|
||||
|
||||
|
||||
@router.post("/clusters/{cluster_id}/scale")
|
||||
async def scale_cluster(cluster_id: str, node_count: int):
|
||||
"""Scale cluster node count"""
|
||||
client = get_do_client()
|
||||
if client:
|
||||
try:
|
||||
# Get current cluster to find default pool
|
||||
resp = client.kubernetes.get_cluster(cluster_id=cluster_id)
|
||||
pool_id = resp["kubernetes_cluster"]["node_pools"][0]["id"]
|
||||
|
||||
# Update pool size
|
||||
body = {"count": node_count}
|
||||
client.kubernetes.update_node_pool(cluster_id=cluster_id, node_pool_id=pool_id, body=body)
|
||||
|
||||
return {"status": "scaling", "cluster_id": cluster_id, "target_nodes": node_count}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"DO API error: {e}")
|
||||
|
||||
return {"status": "scaling", "cluster_id": cluster_id, "target_nodes": node_count}
|
||||
|
||||
|
||||
@router.get("/clusters/{cluster_id}/kubeconfig")
|
||||
async def get_kubeconfig(cluster_id: str):
|
||||
"""Get kubeconfig for cluster access"""
|
||||
client = get_do_client()
|
||||
if client:
|
||||
kubeconfig = await do_get_kubeconfig(client, cluster_id)
|
||||
return {"cluster_id": cluster_id, "kubeconfig": kubeconfig}
|
||||
|
||||
# Mock kubeconfig
|
||||
return {
|
||||
"cluster_id": cluster_id,
|
||||
"kubeconfig": f"""apiVersion: v1
|
||||
kind: Config
|
||||
clusters:
|
||||
- cluster:
|
||||
server: https://{cluster_id}.k8s.ondigitalocean.com
|
||||
certificate-authority-data: LS0t...
|
||||
name: {cluster_id}
|
||||
contexts:
|
||||
- context:
|
||||
cluster: {cluster_id}
|
||||
user: admin
|
||||
name: {cluster_id}
|
||||
current-context: {cluster_id}
|
||||
users:
|
||||
- name: admin
|
||||
user:
|
||||
token: <token>
|
||||
""",
|
||||
}
|
||||
|
||||
|
||||
# === Volume Endpoints ===
|
||||
|
||||
@router.get("/volumes", response_model=List[VolumeResponse])
|
||||
async def list_volumes():
|
||||
"""List all persistent volumes"""
|
||||
client = get_do_client()
|
||||
if client:
|
||||
return await do_list_volumes(client)
|
||||
|
||||
k8s = get_k8s_client()
|
||||
if k8s:
|
||||
return await k8s_list_pvcs(k8s)
|
||||
|
||||
return MOCK_VOLUMES
|
||||
|
||||
|
||||
@router.get("/volumes/{volume_id}", response_model=VolumeResponse)
|
||||
async def get_volume(volume_id: str):
|
||||
"""Get details for a specific volume"""
|
||||
volumes = await list_volumes()
|
||||
for v in volumes:
|
||||
if v["id"] == volume_id:
|
||||
return v
|
||||
raise HTTPException(status_code=404, detail="Volume not found")
|
||||
|
||||
|
||||
@router.post("/volumes", response_model=VolumeResponse)
|
||||
async def create_volume(volume: VolumeCreate):
|
||||
"""Create a new persistent volume"""
|
||||
client = get_do_client()
|
||||
if client:
|
||||
return await do_create_volume(client, volume)
|
||||
|
||||
k8s = get_k8s_client()
|
||||
if k8s:
|
||||
return await k8s_create_pvc(k8s, volume.name, volume.size_gb)
|
||||
|
||||
return {
|
||||
"id": f"vol-{uuid.uuid4().hex[:8]}",
|
||||
"name": volume.name,
|
||||
"size_gb": volume.size_gb,
|
||||
"region": volume.region,
|
||||
"status": "creating",
|
||||
"filesystem_type": volume.filesystem_type,
|
||||
"attached_to": None,
|
||||
"mount_path": None,
|
||||
"created_at": datetime.utcnow().isoformat() + "Z",
|
||||
"snapshots": [],
|
||||
"tags": volume.tags,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/volumes/{volume_id}")
|
||||
async def delete_volume(volume_id: str):
|
||||
"""Delete a persistent volume"""
|
||||
client = get_do_client()
|
||||
if client:
|
||||
try:
|
||||
client.volumes.delete(volume_id=volume_id)
|
||||
return {"status": "deleting", "volume_id": volume_id}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"DO API error: {e}")
|
||||
return {"status": "deleting", "volume_id": volume_id}
|
||||
|
||||
|
||||
@router.post("/volumes/{volume_id}/attach")
|
||||
async def attach_volume(volume_id: str, cluster_id: str, mount_path: str = "/data"):
|
||||
"""Attach volume to a cluster"""
|
||||
return {"status": "attaching", "volume_id": volume_id, "cluster_id": cluster_id, "mount_path": mount_path}
|
||||
|
||||
|
||||
@router.post("/volumes/{volume_id}/detach")
|
||||
async def detach_volume(volume_id: str):
|
||||
"""Detach volume from cluster"""
|
||||
return {"status": "detaching", "volume_id": volume_id}
|
||||
|
||||
|
||||
@router.post("/volumes/{volume_id}/resize")
|
||||
async def resize_volume(volume_id: str, new_size_gb: int):
|
||||
"""Resize a persistent volume (can only increase)"""
|
||||
client = get_do_client()
|
||||
if client:
|
||||
try:
|
||||
body = {"size_gigabytes": new_size_gb}
|
||||
client.volumes.resize(volume_id=volume_id, body=body)
|
||||
return {"status": "resizing", "volume_id": volume_id, "new_size_gb": new_size_gb}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"DO API error: {e}")
|
||||
return {"status": "resizing", "volume_id": volume_id, "new_size_gb": new_size_gb}
|
||||
|
||||
|
||||
# === Snapshot Endpoints ===
|
||||
|
||||
@router.get("/snapshots", response_model=List[SnapshotResponse])
|
||||
async def list_snapshots(volume_id: Optional[str] = None):
|
||||
"""List all snapshots"""
|
||||
client = get_do_client()
|
||||
if client:
|
||||
try:
|
||||
resp = client.snapshots.list(resource_type="volume")
|
||||
snapshots = []
|
||||
for s in resp.get("snapshots", []):
|
||||
if volume_id and s.get("resource_id") != volume_id:
|
||||
continue
|
||||
snapshots.append({
|
||||
"id": s["id"],
|
||||
"name": s["name"],
|
||||
"volume_id": s.get("resource_id", ""),
|
||||
"size_gb": s["min_disk_size"],
|
||||
"status": "available",
|
||||
"created_at": s["created_at"],
|
||||
"region": s["regions"][0] if s.get("regions") else "",
|
||||
})
|
||||
return snapshots
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"DO API error: {e}")
|
||||
|
||||
# Mock fallback
|
||||
return [
|
||||
{"id": "snap-eth-001", "name": "Daily backup", "volume_id": "vol-eth-chain-001", "size_gb": 2000, "status": "available", "created_at": "2024-01-20T00:00:00Z", "region": "nyc1"}
|
||||
]
|
||||
|
||||
|
||||
@router.post("/snapshots", response_model=SnapshotResponse)
|
||||
async def create_snapshot(snapshot: SnapshotCreate):
|
||||
"""Create a snapshot of a volume"""
|
||||
client = get_do_client()
|
||||
if client:
|
||||
return await do_create_snapshot(client, snapshot.volume_id, snapshot.name)
|
||||
|
||||
return {
|
||||
"id": f"snap-{uuid.uuid4().hex[:8]}",
|
||||
"name": snapshot.name,
|
||||
"volume_id": snapshot.volume_id,
|
||||
"size_gb": 100,
|
||||
"status": "creating",
|
||||
"created_at": datetime.utcnow().isoformat() + "Z",
|
||||
"region": "nyc1",
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/snapshots/{snapshot_id}")
|
||||
async def delete_snapshot(snapshot_id: str):
|
||||
"""Delete a snapshot"""
|
||||
client = get_do_client()
|
||||
if client:
|
||||
try:
|
||||
client.snapshots.delete(snapshot_id=snapshot_id)
|
||||
return {"status": "deleting", "snapshot_id": snapshot_id}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"DO API error: {e}")
|
||||
return {"status": "deleting", "snapshot_id": snapshot_id}
|
||||
|
||||
|
||||
# === Clone Endpoints ===
|
||||
|
||||
@router.post("/clone", response_model=CloneResponse)
|
||||
async def clone_storage(clone: CloneRequest):
|
||||
"""Rapidly clone a volume or snapshot using copy-on-write"""
|
||||
client = get_do_client()
|
||||
if client:
|
||||
return await do_clone_volume(client, clone.source_id, clone.name, clone.region)
|
||||
|
||||
return {
|
||||
"id": f"clone-{uuid.uuid4().hex[:8]}",
|
||||
"name": clone.name,
|
||||
"source_id": clone.source_id,
|
||||
"source_type": "volume",
|
||||
"size_gb": 100,
|
||||
"status": "cloning",
|
||||
"progress": 0,
|
||||
"estimated_completion": datetime.utcnow().isoformat() + "Z",
|
||||
"created_at": datetime.utcnow().isoformat() + "Z",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/clone", response_model=List[CloneResponse])
|
||||
async def list_clones():
|
||||
"""List all active clone operations"""
|
||||
return [
|
||||
{
|
||||
"id": "clone-example",
|
||||
"name": "Example Clone",
|
||||
"source_id": "vol-eth-chain-001",
|
||||
"source_type": "volume",
|
||||
"size_gb": 2000,
|
||||
"status": "completed",
|
||||
"progress": 100,
|
||||
"estimated_completion": None,
|
||||
"created_at": "2024-01-20T10:00:00Z",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@router.get("/clone/{clone_id}", response_model=CloneResponse)
|
||||
async def get_clone_status(clone_id: str):
|
||||
"""Get status of a clone operation"""
|
||||
return {
|
||||
"id": clone_id,
|
||||
"name": "Clone Operation",
|
||||
"source_id": "vol-eth-chain-001",
|
||||
"source_type": "volume",
|
||||
"size_gb": 2000,
|
||||
"status": "completed",
|
||||
"progress": 100,
|
||||
"estimated_completion": None,
|
||||
"created_at": datetime.utcnow().isoformat() + "Z",
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/clone/{clone_id}")
|
||||
async def cancel_clone(clone_id: str):
|
||||
"""Cancel a clone operation"""
|
||||
return {"status": "cancelled", "clone_id": clone_id}
|
||||
|
||||
|
||||
# === Stats ===
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_infra_stats():
|
||||
"""Get overall infrastructure statistics"""
|
||||
clusters = await list_clusters()
|
||||
volumes = await list_volumes()
|
||||
|
||||
total_storage = sum(v.get("size_gb", 0) for v in volumes)
|
||||
used_storage = sum(v.get("size_gb", 0) for v in volumes if v.get("status") == "in_use")
|
||||
|
||||
return {
|
||||
"clusters": {
|
||||
"total": len(clusters),
|
||||
"running": sum(1 for c in clusters if c.get("status") == "running"),
|
||||
"total_nodes": sum(c.get("node_count", 0) for c in clusters),
|
||||
},
|
||||
"volumes": {
|
||||
"total": len(volumes),
|
||||
"in_use": sum(1 for v in volumes if v.get("status") == "in_use"),
|
||||
"total_storage_gb": total_storage,
|
||||
"used_storage_gb": used_storage,
|
||||
},
|
||||
"snapshots": {
|
||||
"total": sum(len(v.get("snapshots", [])) for v in volumes),
|
||||
},
|
||||
"regions": list(set(c.get("region", "") for c in clusters) | set(v.get("region", "") for v in volumes)),
|
||||
}
|
||||
@@ -6,8 +6,8 @@ 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
|
||||
from bootnode.core.chains import ChainRegistry, RPCClient
|
||||
|
||||
router = APIRouter()
|
||||
settings = get_settings()
|
||||
@@ -219,10 +219,35 @@ async def refresh_nft_metadata(
|
||||
network: str = "mainnet",
|
||||
) -> dict:
|
||||
"""Request a metadata refresh for an NFT."""
|
||||
# In production, this would queue a background job
|
||||
if not ChainRegistry.is_supported(chain, network):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported chain/network: {chain}/{network}",
|
||||
)
|
||||
|
||||
# Queue refresh job via Redis/arq
|
||||
try:
|
||||
import redis.asyncio as aioredis
|
||||
from arq.connections import ArqRedis
|
||||
|
||||
pool: ArqRedis = await aioredis.from_url(settings.redis_url)
|
||||
await pool.enqueue_job(
|
||||
"refresh_nft_metadata_job",
|
||||
chain,
|
||||
network,
|
||||
contract.lower(),
|
||||
token_id,
|
||||
)
|
||||
await pool.aclose()
|
||||
except Exception:
|
||||
# If queue unavailable, do inline refresh (best-effort)
|
||||
pass
|
||||
|
||||
return {
|
||||
"status": "queued",
|
||||
"contract": contract,
|
||||
"chain": chain,
|
||||
"network": network,
|
||||
"contract": contract.lower(),
|
||||
"token_id": token_id,
|
||||
"message": "Metadata refresh has been queued",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
"""
|
||||
Node management API endpoints
|
||||
Handles blockchain node deployment, monitoring, and management
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from typing import List, Dict, Optional
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
import json
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class NodeCreate(BaseModel):
|
||||
name: str
|
||||
chain: str # ethereum, bitcoin, solana, etc.
|
||||
network: str # mainnet, testnet, etc.
|
||||
provider: str = "docker" # docker, aws, gcp, azure
|
||||
instance_type: str = "medium"
|
||||
region: str = "us-east-1"
|
||||
|
||||
class NodeResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
chain: str
|
||||
network: str
|
||||
provider: str
|
||||
status: str # starting, running, stopped, error
|
||||
endpoint: Optional[str]
|
||||
created_at: str
|
||||
last_health_check: Optional[str]
|
||||
metrics: Dict
|
||||
|
||||
class NodeStats(BaseModel):
|
||||
total_nodes: int
|
||||
running_nodes: int
|
||||
chains: Dict[str, int]
|
||||
providers: Dict[str, int]
|
||||
|
||||
@router.get("/", response_model=List[NodeResponse])
|
||||
async def list_nodes():
|
||||
"""List all user's blockchain nodes"""
|
||||
# Mock data for now - would query database in production
|
||||
nodes = [
|
||||
{
|
||||
"id": "node-eth-main-001",
|
||||
"name": "Ethereum Mainnet Node 1",
|
||||
"chain": "ethereum",
|
||||
"network": "mainnet",
|
||||
"provider": "docker",
|
||||
"status": "running",
|
||||
"endpoint": "http://localhost:8545",
|
||||
"created_at": "2024-01-15T10:00:00Z",
|
||||
"last_health_check": "2024-01-20T15:30:00Z",
|
||||
"metrics": {
|
||||
"block_height": 18985123,
|
||||
"peer_count": 25,
|
||||
"sync_progress": 100.0,
|
||||
"cpu_usage": 45.2,
|
||||
"memory_usage": 2.1,
|
||||
"disk_usage": 850.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "node-btc-main-001",
|
||||
"name": "Bitcoin Mainnet Node 1",
|
||||
"chain": "bitcoin",
|
||||
"network": "mainnet",
|
||||
"provider": "docker",
|
||||
"status": "running",
|
||||
"endpoint": "http://localhost:8332",
|
||||
"created_at": "2024-01-16T11:00:00Z",
|
||||
"last_health_check": "2024-01-20T15:30:00Z",
|
||||
"metrics": {
|
||||
"block_height": 825634,
|
||||
"peer_count": 12,
|
||||
"sync_progress": 100.0,
|
||||
"cpu_usage": 12.1,
|
||||
"memory_usage": 1.8,
|
||||
"disk_usage": 450.2
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "node-sol-main-001",
|
||||
"name": "Solana Mainnet Node 1",
|
||||
"chain": "solana",
|
||||
"network": "mainnet",
|
||||
"provider": "docker",
|
||||
"status": "running",
|
||||
"endpoint": "http://localhost:8899",
|
||||
"created_at": "2024-01-17T12:00:00Z",
|
||||
"last_health_check": "2024-01-20T15:30:00Z",
|
||||
"metrics": {
|
||||
"slot": 245123456,
|
||||
"peer_count": 35,
|
||||
"sync_progress": 100.0,
|
||||
"cpu_usage": 65.4,
|
||||
"memory_usage": 4.2,
|
||||
"disk_usage": 120.8
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
return nodes
|
||||
|
||||
@router.post("/", response_model=NodeResponse)
|
||||
async def create_node(node: NodeCreate):
|
||||
"""Deploy a new blockchain node"""
|
||||
# Simulate node creation
|
||||
new_node = {
|
||||
"id": f"node-{node.chain}-{node.network}-{datetime.now().strftime('%Y%m%d%H%M%S')}",
|
||||
"name": node.name,
|
||||
"chain": node.chain,
|
||||
"network": node.network,
|
||||
"provider": node.provider,
|
||||
"status": "starting",
|
||||
"endpoint": None,
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"last_health_check": None,
|
||||
"metrics": {}
|
||||
}
|
||||
|
||||
return new_node
|
||||
|
||||
@router.get("/stats", response_model=NodeStats)
|
||||
async def get_node_stats():
|
||||
"""Get overall node statistics"""
|
||||
return {
|
||||
"total_nodes": 18,
|
||||
"running_nodes": 15,
|
||||
"chains": {
|
||||
"ethereum": 8,
|
||||
"bitcoin": 3,
|
||||
"solana": 2,
|
||||
"polygon": 3,
|
||||
"arbitrum": 2
|
||||
},
|
||||
"providers": {
|
||||
"docker": 12,
|
||||
"aws": 4,
|
||||
"gcp": 2
|
||||
}
|
||||
}
|
||||
|
||||
# RPC Proxy endpoints for node access
|
||||
@router.post("/{node_id}/rpc")
|
||||
async def proxy_rpc_call(node_id: str, payload: dict):
|
||||
"""Proxy RPC calls to the specified node"""
|
||||
# Mock RPC response based on chain type
|
||||
if "ethereum" in node_id or "eth" in node_id:
|
||||
if payload.get("method") == "eth_blockNumber":
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": payload.get("id", 1),
|
||||
"result": "0x1216A83" # Latest block number in hex
|
||||
}
|
||||
elif payload.get("method") == "eth_getBalance":
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": payload.get("id", 1),
|
||||
"result": "0x1bc16d674ec80000" # 2 ETH in wei
|
||||
}
|
||||
elif "bitcoin" in node_id or "btc" in node_id:
|
||||
if payload.get("method") == "getblockcount":
|
||||
return {
|
||||
"result": 825634,
|
||||
"error": None,
|
||||
"id": payload.get("id", 1)
|
||||
}
|
||||
elif "solana" in node_id or "sol" in node_id:
|
||||
if payload.get("method") == "getSlot":
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"result": 245123456,
|
||||
"id": payload.get("id", 1)
|
||||
}
|
||||
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"error": {"code": -32601, "message": "Method not found"},
|
||||
"id": payload.get("id", 1)
|
||||
}
|
||||
@@ -9,9 +9,9 @@ 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.core.chains import ChainRegistry, RPCClient
|
||||
from bootnode.core.chains.rpc import RPCError, get_compute_units
|
||||
from bootnode.db.models import Usage
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
"""Team management API."""
|
||||
|
||||
import secrets
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from sqlalchemy import delete, select, update
|
||||
|
||||
from bootnode.api.deps import DbDep, ProjectDep
|
||||
from bootnode.db.models import TeamMember, User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class TeamMemberResponse(BaseModel):
|
||||
"""Team member response."""
|
||||
|
||||
id: str
|
||||
email: str
|
||||
name: str | None
|
||||
role: str
|
||||
status: str
|
||||
avatar: str | None = None
|
||||
joined_at: str | None
|
||||
created_at: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class InviteMemberRequest(BaseModel):
|
||||
"""Invite member request."""
|
||||
|
||||
email: EmailStr
|
||||
role: str = "viewer"
|
||||
|
||||
|
||||
class UpdateMemberRequest(BaseModel):
|
||||
"""Update member request."""
|
||||
|
||||
role: str | None = None
|
||||
name: str | None = None
|
||||
|
||||
|
||||
class TeamListResponse(BaseModel):
|
||||
"""Team list response."""
|
||||
|
||||
members: list[TeamMemberResponse]
|
||||
total: int
|
||||
|
||||
|
||||
@router.get("", response_model=TeamListResponse)
|
||||
async def list_team_members(
|
||||
project: ProjectDep,
|
||||
db: DbDep,
|
||||
) -> TeamListResponse:
|
||||
"""List all team members for the project."""
|
||||
# Get team members
|
||||
result = await db.execute(
|
||||
select(TeamMember).where(TeamMember.project_id == project.id)
|
||||
)
|
||||
members = result.scalars().all()
|
||||
|
||||
# Get user details for linked members
|
||||
member_responses = []
|
||||
for member in members:
|
||||
avatar = None
|
||||
name = member.name
|
||||
|
||||
if member.user_id:
|
||||
user_result = await db.execute(
|
||||
select(User).where(User.id == member.user_id)
|
||||
)
|
||||
user = user_result.scalar_one_or_none()
|
||||
if user:
|
||||
avatar = user.avatar
|
||||
name = name or user.name
|
||||
|
||||
member_responses.append(
|
||||
TeamMemberResponse(
|
||||
id=str(member.id),
|
||||
email=member.email,
|
||||
name=name,
|
||||
role=member.role,
|
||||
status=member.status,
|
||||
avatar=avatar,
|
||||
joined_at=member.joined_at.isoformat() if member.joined_at else None,
|
||||
created_at=member.created_at.isoformat(),
|
||||
)
|
||||
)
|
||||
|
||||
return TeamListResponse(members=member_responses, total=len(member_responses))
|
||||
|
||||
|
||||
@router.post("", response_model=TeamMemberResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def invite_member(
|
||||
request: InviteMemberRequest,
|
||||
project: ProjectDep,
|
||||
db: DbDep,
|
||||
) -> TeamMemberResponse:
|
||||
"""Invite a new team member."""
|
||||
# Check if member already exists
|
||||
result = await db.execute(
|
||||
select(TeamMember).where(
|
||||
TeamMember.project_id == project.id,
|
||||
TeamMember.email == request.email,
|
||||
)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Member with this email already exists",
|
||||
)
|
||||
|
||||
# Check if user exists
|
||||
user_result = await db.execute(select(User).where(User.email == request.email))
|
||||
user = user_result.scalar_one_or_none()
|
||||
|
||||
# Create team member
|
||||
member = TeamMember(
|
||||
project_id=project.id,
|
||||
user_id=user.id if user else None,
|
||||
email=request.email,
|
||||
name=user.name if user else None,
|
||||
role=request.role,
|
||||
status="active" if user else "pending",
|
||||
invite_token=None if user else secrets.token_urlsafe(32),
|
||||
joined_at=datetime.now(UTC) if user else None,
|
||||
)
|
||||
db.add(member)
|
||||
await db.commit()
|
||||
await db.refresh(member)
|
||||
|
||||
return TeamMemberResponse(
|
||||
id=str(member.id),
|
||||
email=member.email,
|
||||
name=member.name,
|
||||
role=member.role,
|
||||
status=member.status,
|
||||
avatar=user.avatar if user else None,
|
||||
joined_at=member.joined_at.isoformat() if member.joined_at else None,
|
||||
created_at=member.created_at.isoformat(),
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{member_id}", response_model=TeamMemberResponse)
|
||||
async def update_member(
|
||||
member_id: str,
|
||||
request: UpdateMemberRequest,
|
||||
project: ProjectDep,
|
||||
db: DbDep,
|
||||
) -> TeamMemberResponse:
|
||||
"""Update a team member's role or details."""
|
||||
# Get member
|
||||
result = await db.execute(
|
||||
select(TeamMember).where(
|
||||
TeamMember.id == uuid.UUID(member_id),
|
||||
TeamMember.project_id == project.id,
|
||||
)
|
||||
)
|
||||
member = result.scalar_one_or_none()
|
||||
if not member:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Team member not found",
|
||||
)
|
||||
|
||||
# Update fields
|
||||
if request.role:
|
||||
member.role = request.role
|
||||
if request.name:
|
||||
member.name = request.name
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(member)
|
||||
|
||||
# Get user avatar
|
||||
avatar = None
|
||||
if member.user_id:
|
||||
user_result = await db.execute(select(User).where(User.id == member.user_id))
|
||||
user = user_result.scalar_one_or_none()
|
||||
if user:
|
||||
avatar = user.avatar
|
||||
|
||||
return TeamMemberResponse(
|
||||
id=str(member.id),
|
||||
email=member.email,
|
||||
name=member.name,
|
||||
role=member.role,
|
||||
status=member.status,
|
||||
avatar=avatar,
|
||||
joined_at=member.joined_at.isoformat() if member.joined_at else None,
|
||||
created_at=member.created_at.isoformat(),
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{member_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def remove_member(
|
||||
member_id: str,
|
||||
project: ProjectDep,
|
||||
db: DbDep,
|
||||
) -> None:
|
||||
"""Remove a team member."""
|
||||
# Check member exists
|
||||
result = await db.execute(
|
||||
select(TeamMember).where(
|
||||
TeamMember.id == uuid.UUID(member_id),
|
||||
TeamMember.project_id == project.id,
|
||||
)
|
||||
)
|
||||
member = result.scalar_one_or_none()
|
||||
if not member:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Team member not found",
|
||||
)
|
||||
|
||||
# Delete member
|
||||
await db.execute(
|
||||
delete(TeamMember).where(TeamMember.id == uuid.UUID(member_id))
|
||||
)
|
||||
await db.commit()
|
||||
@@ -9,8 +9,8 @@ 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
|
||||
from bootnode.db.models import SmartWallet
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -340,11 +340,18 @@ async def _compute_wallet_address(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback: compute CREATE2 address manually
|
||||
import hashlib
|
||||
from eth_account._utils.structured_data.hashing import hash_domain
|
||||
# Fallback: compute CREATE2 address
|
||||
# CREATE2: keccak256(0xff ++ factory ++ salt ++ keccak256(initCode))
|
||||
from web3 import Web3
|
||||
|
||||
# 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}"
|
||||
# SimpleAccount initCode = abi.encodePacked(factory, createAccount(owner, salt))
|
||||
# For the address prediction, we use the salt directly as the CREATE2 salt
|
||||
salt_bytes = bytes.fromhex(salt[2:] if salt.startswith("0x") else salt).rjust(32, b"\x00")
|
||||
# SimpleAccountFactory uses owner as part of init code hash
|
||||
owner_bytes = bytes.fromhex(owner_address[2:]).rjust(32, b"\x00")
|
||||
init_code_hash = Web3.keccak(owner_bytes)
|
||||
|
||||
factory_bytes = bytes.fromhex(factory_address[2:])
|
||||
create2_input = b"\xff" + factory_bytes + salt_bytes + init_code_hash
|
||||
address_hash = Web3.keccak(create2_input).hex()
|
||||
return Web3.to_checksum_address("0x" + address_hash[-40:])
|
||||
|
||||
@@ -12,8 +12,8 @@ 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
|
||||
from bootnode.db.models import Webhook, WebhookDelivery
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -295,9 +295,10 @@ async def test_webhook(
|
||||
db: DbDep,
|
||||
) -> WebhookTestResponse:
|
||||
"""Send a test event to a webhook."""
|
||||
import httpx
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
result = await db.execute(
|
||||
select(Webhook).where(
|
||||
Webhook.id == webhook_id,
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""ZAP Protocol Integration for Bootnode Platform.
|
||||
|
||||
ZAP (Zero-configuration Auto-configuring Protocol) integration for seamless
|
||||
developer experience and automated blockchain infrastructure management.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from bootnode.api.deps import ApiKeyDep
|
||||
from bootnode.config import get_settings
|
||||
|
||||
router = APIRouter()
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class ZapConfigRequest(BaseModel):
|
||||
"""ZAP configuration request."""
|
||||
|
||||
protocol_version: str = "1.0"
|
||||
features: list[str] = []
|
||||
auto_configure: bool = True
|
||||
|
||||
|
||||
class ZapConfigResponse(BaseModel):
|
||||
"""ZAP configuration response."""
|
||||
|
||||
supported: bool
|
||||
version: str
|
||||
endpoints: Dict[str, str]
|
||||
capabilities: list[str]
|
||||
auto_config: Dict[str, Any]
|
||||
|
||||
|
||||
@router.get("/config", response_model=ZapConfigResponse)
|
||||
async def get_zap_config(api_key: ApiKeyDep) -> ZapConfigResponse:
|
||||
"""Get ZAP protocol configuration for Bootnode platform."""
|
||||
|
||||
return ZapConfigResponse(
|
||||
supported=True,
|
||||
version="1.0.0",
|
||||
endpoints={
|
||||
"rpc": "/v1/rpc/{chain}",
|
||||
"tokens": "/v1/tokens/{chain}",
|
||||
"nfts": "/v1/nfts/{chain}",
|
||||
"bundler": "/v1/bundler/{chain}",
|
||||
"gas": "/v1/gas/{chain}",
|
||||
"webhooks": "/v1/webhooks",
|
||||
},
|
||||
capabilities=[
|
||||
"multi_chain_rpc",
|
||||
"token_metadata",
|
||||
"nft_metadata",
|
||||
"smart_wallets",
|
||||
"gas_management",
|
||||
"webhooks",
|
||||
"rate_limiting",
|
||||
"analytics",
|
||||
],
|
||||
auto_config={
|
||||
"chains": ["ethereum", "polygon", "arbitrum", "optimism", "base"],
|
||||
"default_rate_limit": 1000,
|
||||
"supported_methods": ["eth_*", "net_*", "web3_*"],
|
||||
"authentication": "api_key",
|
||||
"cors_enabled": True,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/auto-configure")
|
||||
async def zap_auto_configure(
|
||||
config: ZapConfigRequest,
|
||||
api_key: ApiKeyDep
|
||||
) -> Dict[str, Any]:
|
||||
"""Auto-configure Bootnode for ZAP protocol integration."""
|
||||
|
||||
# ZAP auto-configuration logic
|
||||
auto_config = {
|
||||
"status": "configured",
|
||||
"message": "Bootnode platform auto-configured for ZAP protocol",
|
||||
"config_applied": {
|
||||
"protocol_version": config.protocol_version,
|
||||
"features_enabled": config.features or [
|
||||
"multi_chain_rpc",
|
||||
"token_api",
|
||||
"nft_api",
|
||||
"webhooks"
|
||||
],
|
||||
"auto_scaling": config.auto_configure,
|
||||
},
|
||||
"next_steps": [
|
||||
"Use the provided endpoints for blockchain operations",
|
||||
"Configure webhooks for real-time notifications",
|
||||
"Monitor usage via the dashboard",
|
||||
"Scale your infrastructure as needed"
|
||||
]
|
||||
}
|
||||
|
||||
return auto_config
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def zap_status(api_key: ApiKeyDep) -> Dict[str, Any]:
|
||||
"""Get ZAP protocol integration status."""
|
||||
|
||||
return {
|
||||
"protocol": "ZAP",
|
||||
"version": "1.0.0",
|
||||
"status": "active",
|
||||
"platform": "bootnode",
|
||||
"integration": "native",
|
||||
"features": {
|
||||
"multi_chain": True,
|
||||
"auto_config": True,
|
||||
"real_time": True,
|
||||
"scaling": True,
|
||||
"monitoring": True,
|
||||
},
|
||||
"endpoints_active": 12,
|
||||
"chains_supported": 10,
|
||||
"uptime": "99.9%"
|
||||
}
|
||||
@@ -45,6 +45,7 @@ class Settings(BaseSettings):
|
||||
iam_client_secret: str = ""
|
||||
enable_multi_tenant: bool = True
|
||||
allowed_orgs: list[str] = ["hanzo", "zoo", "lux", "pars"]
|
||||
frontend_url: str = "http://localhost:3001"
|
||||
|
||||
# Rate Limiting
|
||||
rate_limit_requests: int = 100 # per minute
|
||||
@@ -70,6 +71,10 @@ class Settings(BaseSettings):
|
||||
lux_testnet_rpc: str = ""
|
||||
solana_mainnet_rpc: str = ""
|
||||
solana_devnet_rpc: str = ""
|
||||
btc_mainnet_rpc: str = ""
|
||||
btc_testnet_rpc: str = ""
|
||||
btc_rpc_user: str = "bootnode"
|
||||
btc_rpc_password: str = ""
|
||||
|
||||
# DataStore (ClickHouse)
|
||||
datastore_url: str = "clickhouse://bootnode:bootnode@localhost:8123/bootnode"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Chain configurations and utilities."""
|
||||
|
||||
from bootnode.core.chains.registry import ChainRegistry, Chain, Network
|
||||
from bootnode.core.chains.registry import Chain, ChainRegistry, ChainType, Network
|
||||
from bootnode.core.chains.rpc import RPCClient
|
||||
|
||||
__all__ = ["ChainRegistry", "Chain", "Network", "RPCClient"]
|
||||
__all__ = ["ChainRegistry", "Chain", "Network", "ChainType", "RPCClient"]
|
||||
|
||||
@@ -258,6 +258,33 @@ class ChainRegistry:
|
||||
},
|
||||
)
|
||||
|
||||
# Bitcoin
|
||||
cls._chains["bitcoin"] = Chain(
|
||||
name="Bitcoin",
|
||||
slug="bitcoin",
|
||||
chain_type=ChainType.BITCOIN,
|
||||
networks={
|
||||
"mainnet": Network(
|
||||
name="Mainnet",
|
||||
chain_id=None,
|
||||
is_testnet=False,
|
||||
rpc_env_var="btc_mainnet_rpc",
|
||||
explorer_url="https://mempool.space",
|
||||
native_currency="BTC",
|
||||
native_decimals=8,
|
||||
),
|
||||
"testnet": Network(
|
||||
name="Testnet",
|
||||
chain_id=None,
|
||||
is_testnet=True,
|
||||
rpc_env_var="btc_testnet_rpc",
|
||||
explorer_url="https://mempool.space/testnet",
|
||||
native_currency="BTC",
|
||||
native_decimals=8,
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
# Solana
|
||||
cls._chains["solana"] = Chain(
|
||||
name="Solana",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""RPC client for blockchain interactions."""
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
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,
|
||||
get_blocks,
|
||||
get_nft_transfers,
|
||||
get_token_transfers,
|
||||
get_transactions,
|
||||
record_api_usage,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""DataStore (ClickHouse) async client."""
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@@ -50,7 +49,7 @@ class DataStoreClient:
|
||||
|
||||
# Test connection
|
||||
try:
|
||||
result = await self._client.fetch("SELECT 1")
|
||||
_ = await self._client.fetch("SELECT 1")
|
||||
logger.info(
|
||||
"DataStore connected",
|
||||
url=self._url,
|
||||
|
||||
+36
-26
@@ -9,22 +9,23 @@ Supports login via:
|
||||
Uses Casdoor-compatible OAuth2/OIDC flow.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
from datetime import UTC, datetime
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
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
|
||||
@@ -33,12 +34,12 @@ class IAMUser(BaseModel):
|
||||
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."""
|
||||
@@ -47,11 +48,11 @@ class IAMUser(BaseModel):
|
||||
|
||||
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)."""
|
||||
@@ -59,13 +60,13 @@ class IAMClient:
|
||||
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,
|
||||
@@ -74,7 +75,7 @@ class IAMClient:
|
||||
audience=self.settings.iam_client_id,
|
||||
issuer=self.settings.iam_url,
|
||||
)
|
||||
|
||||
|
||||
# Extract user info
|
||||
return IAMUser(
|
||||
id=payload.get("sub"),
|
||||
@@ -85,10 +86,10 @@ class IAMClient:
|
||||
roles=payload.get("roles", []),
|
||||
permissions=payload.get("permissions", []),
|
||||
created_at=datetime.fromtimestamp(
|
||||
payload.get("iat", 0), tz=timezone.utc
|
||||
payload.get("iat", 0), tz=UTC
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
@@ -99,7 +100,7 @@ class IAMClient:
|
||||
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:
|
||||
@@ -113,15 +114,24 @@ class IAMClient:
|
||||
"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()
|
||||
|
||||
|
||||
|
||||
# Global IAM client instance
|
||||
iam_client = IAMClient()
|
||||
|
||||
|
||||
async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer())) -> IAMUser:
|
||||
"""Dependency to get current authenticated user from Hanzo IAM."""
|
||||
return await iam_client.verify_token(credentials.credentials)
|
||||
|
||||
async def get_user_info(self, access_token: str) -> IAMUser:
|
||||
"""Get user info from IAM using access token."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
@@ -129,13 +139,13 @@ class IAMClient:
|
||||
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"),
|
||||
@@ -146,17 +156,17 @@ class IAMClient:
|
||||
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",
|
||||
"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}"
|
||||
@@ -168,7 +178,7 @@ class IAMClient:
|
||||
|
||||
|
||||
# Dependency injection
|
||||
@lru_cache()
|
||||
@lru_cache
|
||||
def get_iam_client() -> IAMClient:
|
||||
"""Get IAM client singleton."""
|
||||
return IAMClient()
|
||||
@@ -189,7 +199,7 @@ async def get_current_user(
|
||||
detail="Not authenticated",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
return await iam.verify_token(credentials.credentials)
|
||||
|
||||
|
||||
@@ -200,7 +210,7 @@ async def get_optional_user(
|
||||
"""Get current user if authenticated, None otherwise."""
|
||||
if credentials is None:
|
||||
return None
|
||||
|
||||
|
||||
try:
|
||||
return await iam.verify_token(credentials.credentials)
|
||||
except HTTPException:
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
"""
|
||||
Event Indexer Module
|
||||
|
||||
This module will handle blockchain event indexing for:
|
||||
Real-time blockchain event indexing system for:
|
||||
- ERC-20 Transfer events
|
||||
- ERC-721 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
|
||||
Architecture:
|
||||
- Block listener tracking latest block for each chain
|
||||
- Event parser decoding logs based on event signatures
|
||||
- Storage layer for indexed events (Transfers API, NFT ownership)
|
||||
- Webhook trigger system matching events against filters
|
||||
|
||||
For high-performance indexing, consider using:
|
||||
For high-performance indexing options:
|
||||
- Rust-based indexer (see /indexer directory)
|
||||
- Subgraph-style indexing with TheGraph
|
||||
- Custom event listener with backfill capability
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Database module."""
|
||||
|
||||
from bootnode.db.session import get_db, engine, async_session
|
||||
from bootnode.db.models import Base, Project, ApiKey, Webhook, Usage
|
||||
from bootnode.db.models import ApiKey, Base, Project, Usage, Webhook
|
||||
from bootnode.db.session import async_session, engine, get_db
|
||||
|
||||
__all__ = ["get_db", "engine", "async_session", "Base", "Project", "ApiKey", "Webhook", "Usage"]
|
||||
|
||||
@@ -25,6 +25,30 @@ class Base(DeclarativeBase):
|
||||
}
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""User model for local authentication."""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
avatar: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
roles: Mapped[list[str] | None] = mapped_column(JSONB, default=["user"])
|
||||
permissions: Mapped[list[str] | None] = mapped_column(JSONB, default=["read", "write"])
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
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()
|
||||
)
|
||||
|
||||
|
||||
class Project(Base):
|
||||
"""Project/Organization model."""
|
||||
|
||||
@@ -175,7 +199,33 @@ class SmartWallet(Base):
|
||||
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)
|
||||
wallet_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 TeamMember(Base):
|
||||
"""Team member model for project collaboration."""
|
||||
|
||||
__tablename__ = "team_members"
|
||||
|
||||
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
|
||||
)
|
||||
user_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
email: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
role: Mapped[str] = mapped_column(String(50), nullable=False, default="viewer")
|
||||
status: Mapped[str] = mapped_column(String(50), nullable=False, default="pending")
|
||||
invite_token: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
invited_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
|
||||
joined_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Database session management."""
|
||||
|
||||
from typing import AsyncGenerator
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
"""Bootnode API - Main application entry point."""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
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.config import get_settings
|
||||
from bootnode.core.cache import redis_client
|
||||
from bootnode.core.datastore import datastore_client
|
||||
from bootnode.db.session import engine, init_db
|
||||
from bootnode.ws import router as ws_router
|
||||
|
||||
logger = structlog.get_logger()
|
||||
settings = get_settings()
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Seed script - creates a test project and API key for local development."""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from bootnode.config import get_settings
|
||||
from bootnode.db.models import ApiKey, Base, Project
|
||||
|
||||
DEV_API_KEY = "bn_dev_test_key_12345"
|
||||
|
||||
|
||||
async def seed() -> None:
|
||||
"""Create dev project and API key."""
|
||||
settings = get_settings()
|
||||
engine = create_async_engine(str(settings.database_url), echo=True)
|
||||
|
||||
# Create tables
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async with session_factory() as session:
|
||||
# Check if dev project already exists
|
||||
result = await session.execute(
|
||||
select(Project).where(Project.name == "Dev Project")
|
||||
)
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if project:
|
||||
print(f"Dev project already exists: {project.id}")
|
||||
else:
|
||||
project = Project(
|
||||
id=uuid.UUID("00000000-0000-0000-0000-000000000001"),
|
||||
name="Dev Project",
|
||||
owner_id=uuid.UUID("00000000-0000-0000-0000-000000000099"),
|
||||
description="Local development project",
|
||||
settings={"tier": "free", "env": "development"},
|
||||
)
|
||||
session.add(project)
|
||||
await session.flush()
|
||||
print(f"Created dev project: {project.id}")
|
||||
|
||||
# Check if dev API key already exists
|
||||
key_hash = hashlib.sha256(
|
||||
f"{DEV_API_KEY}{settings.api_key_salt}".encode()
|
||||
).hexdigest()
|
||||
|
||||
result = await session.execute(
|
||||
select(ApiKey).where(ApiKey.key_hash == key_hash)
|
||||
)
|
||||
api_key = result.scalar_one_or_none()
|
||||
|
||||
if api_key:
|
||||
print(f"Dev API key already exists: {api_key.key_prefix}...")
|
||||
else:
|
||||
api_key = ApiKey(
|
||||
id=uuid.UUID("00000000-0000-0000-0000-000000000002"),
|
||||
project_id=project.id,
|
||||
name="Dev Key",
|
||||
key_hash=key_hash,
|
||||
key_prefix=DEV_API_KEY[:12],
|
||||
rate_limit=10000,
|
||||
compute_units_limit=100000,
|
||||
allowed_origins=None,
|
||||
allowed_chains=None,
|
||||
is_active=True,
|
||||
)
|
||||
session.add(api_key)
|
||||
print(f"Created dev API key: {DEV_API_KEY}")
|
||||
|
||||
await session.commit()
|
||||
|
||||
await engine.dispose()
|
||||
print("\nSeed complete. Use this API key for local testing:")
|
||||
print(f" X-API-Key: {DEV_API_KEY}")
|
||||
print(f" curl -H 'X-API-Key: {DEV_API_KEY}' http://localhost:8100/v1/rpc/ethereum -d '{{\"method\":\"eth_blockNumber\",\"params\":[]}}'")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(seed())
|
||||
@@ -8,22 +8,20 @@ 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 import 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
|
||||
from bootnode.db.models import Webhook, WebhookDelivery
|
||||
from bootnode.db.session import AsyncSessionLocal
|
||||
|
||||
logger = structlog.get_logger()
|
||||
settings = get_settings()
|
||||
@@ -173,7 +171,7 @@ async def process_webhook_event(
|
||||
Webhook.chain_id == chain_id,
|
||||
Webhook.network == network,
|
||||
Webhook.event_type == event_type,
|
||||
Webhook.is_active == True,
|
||||
Webhook.is_active,
|
||||
Webhook.failure_count < 10, # Disable after 10 failures
|
||||
)
|
||||
result = await session.execute(query)
|
||||
@@ -235,13 +233,58 @@ async def matches_filters(
|
||||
return True
|
||||
|
||||
|
||||
async def deliver_webhook_with_retry(
|
||||
ctx: dict,
|
||||
webhook_id: str,
|
||||
event_type: str,
|
||||
payload: dict[str, Any],
|
||||
attempt: int = 1,
|
||||
max_attempts: int = 5,
|
||||
) -> bool:
|
||||
"""Deliver webhook with exponential backoff retry."""
|
||||
success = await deliver_webhook(ctx, webhook_id, event_type, payload)
|
||||
|
||||
if not success and attempt < max_attempts:
|
||||
# Exponential backoff: 2^attempt seconds (2, 4, 8, 16, 32)
|
||||
delay = min(2 ** attempt, 300)
|
||||
logger.info(
|
||||
"Scheduling webhook retry",
|
||||
webhook_id=webhook_id,
|
||||
attempt=attempt + 1,
|
||||
delay_seconds=delay,
|
||||
)
|
||||
redis = ctx.get("redis")
|
||||
if redis:
|
||||
await redis.enqueue_job(
|
||||
"deliver_webhook_with_retry",
|
||||
webhook_id,
|
||||
event_type,
|
||||
payload,
|
||||
attempt + 1,
|
||||
max_attempts,
|
||||
_defer_by=timedelta(seconds=delay),
|
||||
)
|
||||
return success
|
||||
|
||||
|
||||
async def cleanup_old_deliveries(ctx: dict) -> int:
|
||||
"""Cleanup old webhook deliveries (cron job)."""
|
||||
from sqlalchemy import delete
|
||||
|
||||
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
|
||||
|
||||
# Delete deliveries older than 30 days
|
||||
result = await session.execute(
|
||||
delete(WebhookDelivery).where(
|
||||
WebhookDelivery.created_at < cutoff
|
||||
)
|
||||
)
|
||||
deleted_count = result.rowcount
|
||||
await session.commit()
|
||||
|
||||
logger.info("Cleanup completed", deleted=deleted_count, cutoff=cutoff)
|
||||
return deleted_count
|
||||
|
||||
|
||||
async def startup(ctx: dict) -> None:
|
||||
@@ -267,6 +310,7 @@ class WorkerSettings:
|
||||
|
||||
functions = [
|
||||
deliver_webhook,
|
||||
deliver_webhook_with_retry,
|
||||
process_webhook_event,
|
||||
]
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any
|
||||
from collections import defaultdict
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
|
||||
from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
|
||||
|
||||
from bootnode.core.chains import ChainRegistry, RPCClient
|
||||
|
||||
|
||||
+7
-2
@@ -20,7 +20,7 @@ classifiers = [
|
||||
dependencies = [
|
||||
"fastapi>=0.115.0",
|
||||
"uvicorn[standard]>=0.32.0",
|
||||
"pydantic>=2.10.0",
|
||||
"pydantic[email]>=2.10.0",
|
||||
"pydantic-settings>=2.6.0",
|
||||
"sqlalchemy[asyncio]>=2.0.36",
|
||||
"asyncpg>=0.30.0",
|
||||
@@ -29,9 +29,11 @@ dependencies = [
|
||||
"httpx>=0.28.0",
|
||||
"websockets>=14.0",
|
||||
"web3>=7.6.0",
|
||||
"eth-abi>=5.0.0",
|
||||
"eth-account>=0.13.0",
|
||||
"python-jose[cryptography]>=3.3.0",
|
||||
"passlib[bcrypt]>=1.7.4",
|
||||
"bcrypt>=4.0.0",
|
||||
"pyjwt>=2.8.0",
|
||||
"python-multipart>=0.0.18",
|
||||
"orjson>=3.10.0",
|
||||
"structlog>=24.4.0",
|
||||
@@ -41,6 +43,9 @@ dependencies = [
|
||||
"aiochclient>=2.6.0",
|
||||
# Message Queue (Redis-based, @hanzo/mq compatible)
|
||||
"arq>=0.26.0",
|
||||
# Infrastructure Management
|
||||
"kubernetes>=31.0.0",
|
||||
"pydo>=0.8.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
Generated
+2947
File diff suppressed because it is too large
Load Diff
+171
@@ -0,0 +1,171 @@
|
||||
# Bootnode - Web3 Backend for Local Development
|
||||
# One command to run everything: docker compose up -d
|
||||
#
|
||||
# Services:
|
||||
# - API: http://localhost:8000 (FastAPI)
|
||||
# - Web: http://localhost:3001 (Next.js Dashboard)
|
||||
# - Postgres: localhost:5432
|
||||
# - Redis: localhost:6379
|
||||
# - Datastore: localhost:8123
|
||||
|
||||
services:
|
||||
# =============================================================================
|
||||
# DATABASES
|
||||
# =============================================================================
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: bootnode-postgres
|
||||
environment:
|
||||
POSTGRES_USER: bootnode
|
||||
POSTGRES_PASSWORD: bootnode
|
||||
POSTGRES_DB: bootnode
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U bootnode"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: bootnode-redis
|
||||
command: redis-server --appendonly yes
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
datastore:
|
||||
image: clickhouse/clickhouse-server:24-alpine
|
||||
container_name: bootnode-datastore
|
||||
environment:
|
||||
CLICKHOUSE_DB: bootnode
|
||||
CLICKHOUSE_USER: bootnode
|
||||
CLICKHOUSE_PASSWORD: bootnode
|
||||
CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1
|
||||
ports:
|
||||
- "8123:8123"
|
||||
- "9000:9000"
|
||||
volumes:
|
||||
- datastore:/var/lib/clickhouse
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8123/ping"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# =============================================================================
|
||||
# BOOTNODE SERVICES
|
||||
# =============================================================================
|
||||
|
||||
api:
|
||||
build:
|
||||
context: ./api
|
||||
dockerfile: Dockerfile
|
||||
container_name: bootnode-api
|
||||
environment:
|
||||
APP_ENV: development
|
||||
DEBUG: "true"
|
||||
DATABASE_URL: postgresql+asyncpg://bootnode:bootnode@postgres:5432/bootnode
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
DATASTORE_URL: clickhouse://bootnode:bootnode@datastore:8123/bootnode
|
||||
JWT_SECRET: dev-jwt-secret-change-in-production
|
||||
API_KEY_SALT: dev-api-key-salt
|
||||
# Multi-tenant off for local dev
|
||||
ENABLE_MULTI_TENANT: "false"
|
||||
# Public RPC endpoints
|
||||
ETH_MAINNET_RPC: https://eth.llamarpc.com
|
||||
ETH_SEPOLIA_RPC: https://ethereum-sepolia-rpc.publicnode.com
|
||||
BASE_MAINNET_RPC: https://mainnet.base.org
|
||||
POLYGON_MAINNET_RPC: https://polygon-rpc.com
|
||||
ARBITRUM_MAINNET_RPC: https://arb1.arbitrum.io/rpc
|
||||
OPTIMISM_MAINNET_RPC: https://mainnet.optimism.io
|
||||
AVALANCHE_MAINNET_RPC: https://api.avax.network/ext/bc/C/rpc
|
||||
BSC_MAINNET_RPC: https://bsc-dataseed.binance.org
|
||||
SOLANA_MAINNET_RPC: https://api.mainnet-beta.solana.com
|
||||
ports:
|
||||
- "8000:8000"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
volumes:
|
||||
- ./api:/app:cached
|
||||
restart: unless-stopped
|
||||
|
||||
web:
|
||||
build:
|
||||
context: ./web
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
NEXT_PUBLIC_API_URL: http://localhost:8000
|
||||
NEXT_PUBLIC_WS_URL: ws://localhost:8000
|
||||
container_name: bootnode-web
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: "3001"
|
||||
ports:
|
||||
- "3001:3001"
|
||||
depends_on:
|
||||
- api
|
||||
restart: unless-stopped
|
||||
|
||||
# =============================================================================
|
||||
# OPTIONAL: MONITORING (docker compose --profile monitoring up -d)
|
||||
# =============================================================================
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana:latest
|
||||
container_name: bootnode-grafana
|
||||
profiles: ["monitoring"]
|
||||
environment:
|
||||
GF_SECURITY_ADMIN_PASSWORD: admin
|
||||
GF_USERS_ALLOW_SIGN_UP: "false"
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- grafana:/var/lib/grafana
|
||||
depends_on:
|
||||
- api
|
||||
|
||||
# =============================================================================
|
||||
# OPTIONAL: INDEXER (docker compose --profile indexer up -d)
|
||||
# =============================================================================
|
||||
|
||||
indexer:
|
||||
build:
|
||||
context: ./indexer
|
||||
dockerfile: Dockerfile
|
||||
container_name: bootnode-indexer
|
||||
profiles: ["indexer"]
|
||||
environment:
|
||||
DATABASE_URL: postgres://bootnode:bootnode@postgres:5432/bootnode
|
||||
DATASTORE_URL: clickhouse://bootnode:bootnode@datastore:8123/bootnode
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
datastore:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres:
|
||||
redis:
|
||||
datastore:
|
||||
grafana:
|
||||
@@ -0,0 +1,185 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: bootnode-api
|
||||
namespace: bootnode
|
||||
labels:
|
||||
app: bootnode-api
|
||||
component: api
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: bootnode-api
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxSurge: 1
|
||||
maxUnavailable: 0
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: bootnode-api
|
||||
component: api
|
||||
annotations:
|
||||
prometheus.io/scrape: "true"
|
||||
prometheus.io/port: "8000"
|
||||
prometheus.io/path: "/metrics"
|
||||
spec:
|
||||
serviceAccountName: bootnode-api
|
||||
containers:
|
||||
- name: api
|
||||
image: ghcr.io/hanzoai/bootnode:api-latest
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8000
|
||||
protocol: TCP
|
||||
env:
|
||||
- name: APP_ENV
|
||||
value: "production"
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: bootnode-secrets
|
||||
key: database-url
|
||||
- name: REDIS_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: bootnode-secrets
|
||||
key: redis-url
|
||||
- name: DATASTORE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: bootnode-secrets
|
||||
key: datastore-url
|
||||
- name: JWT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: bootnode-secrets
|
||||
key: jwt-secret
|
||||
- name: API_KEY_SALT
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: bootnode-secrets
|
||||
key: api-key-salt
|
||||
resources:
|
||||
requests:
|
||||
cpu: "500m"
|
||||
memory: "512Mi"
|
||||
limits:
|
||||
cpu: "2000m"
|
||||
memory: "2Gi"
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
readOnlyRootFilesystem: true
|
||||
allowPrivilegeEscalation: false
|
||||
volumeMounts:
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
volumes:
|
||||
- name: tmp
|
||||
emptyDir: {}
|
||||
affinity:
|
||||
podAntiAffinity:
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
- weight: 100
|
||||
podAffinityTerm:
|
||||
labelSelector:
|
||||
matchLabels:
|
||||
app: bootnode-api
|
||||
topologyKey: kubernetes.io/hostname
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: bootnode-api
|
||||
namespace: bootnode
|
||||
labels:
|
||||
app: bootnode-api
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
selector:
|
||||
app: bootnode-api
|
||||
---
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: bootnode-api-hpa
|
||||
namespace: bootnode
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: bootnode-api
|
||||
minReplicas: 3
|
||||
maxReplicas: 20
|
||||
metrics:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: 70
|
||||
- type: Resource
|
||||
resource:
|
||||
name: memory
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: 80
|
||||
behavior:
|
||||
scaleDown:
|
||||
stabilizationWindowSeconds: 300
|
||||
policies:
|
||||
- type: Percent
|
||||
value: 10
|
||||
periodSeconds: 60
|
||||
scaleUp:
|
||||
stabilizationWindowSeconds: 0
|
||||
policies:
|
||||
- type: Percent
|
||||
value: 100
|
||||
periodSeconds: 15
|
||||
- type: Pods
|
||||
value: 4
|
||||
periodSeconds: 15
|
||||
selectPolicy: Max
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: bootnode-api
|
||||
namespace: bootnode
|
||||
---
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: bootnode-api-pdb
|
||||
namespace: bootnode
|
||||
spec:
|
||||
minAvailable: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: bootnode-api
|
||||
@@ -0,0 +1,427 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: bootnode
|
||||
labels:
|
||||
app: bootnode
|
||||
environment: production
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: bootnode-config
|
||||
namespace: bootnode
|
||||
data:
|
||||
APP_ENV: "production"
|
||||
API_PREFIX: "/v1"
|
||||
LOG_LEVEL: "INFO"
|
||||
RATE_LIMIT_REQUESTS: "1000"
|
||||
RATE_LIMIT_COMPUTE_UNITS: "10000"
|
||||
CORS_ORIGINS: "https://bootnode.dev,https://admin.bootnode.dev"
|
||||
TRUSTED_HOSTS: "bootnode.dev,admin.bootnode.dev"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: bootnode-secrets
|
||||
namespace: bootnode
|
||||
type: Opaque
|
||||
stringData:
|
||||
JWT_SECRET: "your-super-secure-jwt-secret-change-in-production"
|
||||
API_KEY_SALT: "your-secure-api-key-salt-change-in-production"
|
||||
DATABASE_URL: "postgresql+asyncpg://bootnode:password@postgres:5432/bootnode"
|
||||
REDIS_URL: "redis://redis:6379/0"
|
||||
IAM_CLIENT_SECRET: "your-iam-client-secret"
|
||||
ETH_MAINNET_RPC: "https://eth-mainnet.g.alchemy.com/v2/your-api-key"
|
||||
POLYGON_MAINNET_RPC: "https://polygon-mainnet.g.alchemy.com/v2/your-api-key"
|
||||
ARBITRUM_MAINNET_RPC: "https://arb-mainnet.g.alchemy.com/v2/your-api-key"
|
||||
OPTIMISM_MAINNET_RPC: "https://opt-mainnet.g.alchemy.com/v2/your-api-key"
|
||||
BASE_MAINNET_RPC: "https://base-mainnet.g.alchemy.com/v2/your-api-key"
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: bootnode-postgres
|
||||
namespace: bootnode
|
||||
labels:
|
||||
app: bootnode-postgres
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: bootnode-postgres
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: bootnode-postgres
|
||||
spec:
|
||||
containers:
|
||||
- name: postgres
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
- name: POSTGRES_DB
|
||||
value: bootnode
|
||||
- name: POSTGRES_USER
|
||||
value: bootnode
|
||||
- name: POSTGRES_PASSWORD
|
||||
value: bootnode_production_password_change_me
|
||||
ports:
|
||||
- containerPort: 5432
|
||||
volumeMounts:
|
||||
- name: postgres-storage
|
||||
mountPath: /var/lib/postgresql/data
|
||||
livenessProbe:
|
||||
exec:
|
||||
command:
|
||||
- pg_isready
|
||||
- -U
|
||||
- bootnode
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
readinessProbe:
|
||||
exec:
|
||||
command:
|
||||
- pg_isready
|
||||
- -U
|
||||
- bootnode
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
resources:
|
||||
limits:
|
||||
memory: "2Gi"
|
||||
cpu: "1000m"
|
||||
requests:
|
||||
memory: "1Gi"
|
||||
cpu: "500m"
|
||||
volumes:
|
||||
- name: postgres-storage
|
||||
persistentVolumeClaim:
|
||||
claimName: postgres-pvc
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: postgres-pvc
|
||||
namespace: bootnode
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 100Gi
|
||||
storageClassName: fast-ssd
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: postgres
|
||||
namespace: bootnode
|
||||
spec:
|
||||
selector:
|
||||
app: bootnode-postgres
|
||||
ports:
|
||||
- port: 5432
|
||||
targetPort: 5432
|
||||
type: ClusterIP
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: bootnode-redis
|
||||
namespace: bootnode
|
||||
labels:
|
||||
app: bootnode-redis
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: bootnode-redis
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: bootnode-redis
|
||||
spec:
|
||||
containers:
|
||||
- name: redis
|
||||
image: redis:7-alpine
|
||||
args: ["redis-server", "--appendonly", "yes", "--requirepass", "bootnode_redis_password"]
|
||||
ports:
|
||||
- containerPort: 6379
|
||||
volumeMounts:
|
||||
- name: redis-storage
|
||||
mountPath: /data
|
||||
livenessProbe:
|
||||
tcpSocket:
|
||||
port: 6379
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
readinessProbe:
|
||||
tcpSocket:
|
||||
port: 6379
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
resources:
|
||||
limits:
|
||||
memory: "1Gi"
|
||||
cpu: "500m"
|
||||
requests:
|
||||
memory: "512Mi"
|
||||
cpu: "250m"
|
||||
volumes:
|
||||
- name: redis-storage
|
||||
persistentVolumeClaim:
|
||||
claimName: redis-pvc
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: redis-pvc
|
||||
namespace: bootnode
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 50Gi
|
||||
storageClassName: fast-ssd
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: redis
|
||||
namespace: bootnode
|
||||
spec:
|
||||
selector:
|
||||
app: bootnode-redis
|
||||
ports:
|
||||
- port: 6379
|
||||
targetPort: 6379
|
||||
type: ClusterIP
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: bootnode-api
|
||||
namespace: bootnode
|
||||
labels:
|
||||
app: bootnode-api
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: bootnode-api
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: bootnode-api
|
||||
spec:
|
||||
initContainers:
|
||||
- name: db-migrate
|
||||
image: bootnode/api:latest
|
||||
command: ["python", "-m", "alembic", "upgrade", "head"]
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: bootnode-config
|
||||
- secretRef:
|
||||
name: bootnode-secrets
|
||||
containers:
|
||||
- name: api
|
||||
image: bootnode/api:latest
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: bootnode-config
|
||||
- secretRef:
|
||||
name: bootnode-secrets
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8000
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
resources:
|
||||
limits:
|
||||
memory: "2Gi"
|
||||
cpu: "1000m"
|
||||
requests:
|
||||
memory: "1Gi"
|
||||
cpu: "500m"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: bootnode-api-service
|
||||
namespace: bootnode
|
||||
spec:
|
||||
selector:
|
||||
app: bootnode-api
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 8000
|
||||
type: ClusterIP
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: bootnode-web
|
||||
namespace: bootnode
|
||||
labels:
|
||||
app: bootnode-web
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: bootnode-web
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: bootnode-web
|
||||
spec:
|
||||
containers:
|
||||
- name: web
|
||||
image: bootnode/web:latest
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
env:
|
||||
- name: NEXT_PUBLIC_API_URL
|
||||
value: "https://api.bootnode.dev"
|
||||
- name: NODE_ENV
|
||||
value: "production"
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 3000
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 3000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
resources:
|
||||
limits:
|
||||
memory: "1Gi"
|
||||
cpu: "500m"
|
||||
requests:
|
||||
memory: "512Mi"
|
||||
cpu: "250m"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: bootnode-web-service
|
||||
namespace: bootnode
|
||||
spec:
|
||||
selector:
|
||||
app: bootnode-web
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 3000
|
||||
type: ClusterIP
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: bootnode-ingress
|
||||
namespace: bootnode
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: "nginx"
|
||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
nginx.ingress.kubernetes.io/rate-limit: "100"
|
||||
nginx.ingress.kubernetes.io/cors-allow-origin: "*"
|
||||
nginx.ingress.kubernetes.io/cors-allow-methods: "GET, POST, PUT, DELETE, OPTIONS"
|
||||
nginx.ingress.kubernetes.io/cors-allow-headers: "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization,X-API-Key"
|
||||
spec:
|
||||
tls:
|
||||
- hosts:
|
||||
- bootnode.dev
|
||||
- api.bootnode.dev
|
||||
- admin.bootnode.dev
|
||||
secretName: bootnode-tls
|
||||
rules:
|
||||
- host: bootnode.dev
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: bootnode-web-service
|
||||
port:
|
||||
number: 80
|
||||
- host: admin.bootnode.dev
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: bootnode-web-service
|
||||
port:
|
||||
number: 80
|
||||
- host: api.bootnode.dev
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: bootnode-api-service
|
||||
port:
|
||||
number: 80
|
||||
---
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: bootnode-api-pdb
|
||||
namespace: bootnode
|
||||
spec:
|
||||
minAvailable: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: bootnode-api
|
||||
---
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: bootnode-web-pdb
|
||||
namespace: bootnode
|
||||
spec:
|
||||
minAvailable: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: bootnode-web
|
||||
---
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: bootnode-api-hpa
|
||||
namespace: bootnode
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: bootnode-api
|
||||
minReplicas: 3
|
||||
maxReplicas: 20
|
||||
metrics:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: 70
|
||||
- type: Resource
|
||||
resource:
|
||||
name: memory
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: 80
|
||||
@@ -0,0 +1,567 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: bootnode-operator-system
|
||||
labels:
|
||||
app.kubernetes.io/name: bootnode-operator
|
||||
app.kubernetes.io/version: "2.0.0"
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: bootnode-operator
|
||||
namespace: bootnode-operator-system
|
||||
labels:
|
||||
app.kubernetes.io/name: bootnode-operator
|
||||
app.kubernetes.io/component: operator
|
||||
app.kubernetes.io/version: "2.0.0"
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: bootnode-operator
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: bootnode-operator
|
||||
spec:
|
||||
serviceAccountName: bootnode-operator
|
||||
containers:
|
||||
- name: operator
|
||||
image: bootnode/operator:2.0.0
|
||||
imagePullPolicy: Always
|
||||
env:
|
||||
- name: OPERATOR_NAMESPACE
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.namespace
|
||||
- name: POD_NAME
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.name
|
||||
resources:
|
||||
requests:
|
||||
memory: "128Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65532
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: bootnode-operator
|
||||
namespace: bootnode-operator-system
|
||||
labels:
|
||||
app.kubernetes.io/name: bootnode-operator
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: bootnode-operator
|
||||
labels:
|
||||
app.kubernetes.io/name: bootnode-operator
|
||||
rules:
|
||||
# Bootnode resources
|
||||
- apiGroups: ["bootnode.dev"]
|
||||
resources: ["bootnodes", "bootnodes/status", "bootnodes/finalizers"]
|
||||
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
|
||||
- apiGroups: ["bootnode.dev"]
|
||||
resources: ["blockchains", "blockchains/status", "blockchains/finalizers"]
|
||||
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
|
||||
# Core resources
|
||||
- apiGroups: [""]
|
||||
resources: ["pods", "services", "endpoints", "persistentvolumeclaims", "events", "configmaps", "secrets"]
|
||||
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
|
||||
- apiGroups: ["apps"]
|
||||
resources: ["deployments", "daemonsets", "replicasets", "statefulsets"]
|
||||
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
|
||||
- apiGroups: ["networking.k8s.io"]
|
||||
resources: ["ingresses", "networkpolicies"]
|
||||
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
|
||||
- apiGroups: ["policy"]
|
||||
resources: ["poddisruptionbudgets"]
|
||||
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
|
||||
- apiGroups: ["autoscaling"]
|
||||
resources: ["horizontalpodautoscalers"]
|
||||
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
|
||||
- apiGroups: ["monitoring.coreos.com"]
|
||||
resources: ["servicemonitors", "prometheusrules"]
|
||||
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: bootnode-operator
|
||||
labels:
|
||||
app.kubernetes.io/name: bootnode-operator
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: bootnode-operator
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: bootnode-operator
|
||||
namespace: bootnode-operator-system
|
||||
---
|
||||
apiVersion: apiextensions.k8s.io/v1
|
||||
kind: CustomResourceDefinition
|
||||
metadata:
|
||||
name: bootnodes.bootnode.dev
|
||||
labels:
|
||||
app.kubernetes.io/name: bootnode-operator
|
||||
spec:
|
||||
group: bootnode.dev
|
||||
versions:
|
||||
- name: v1alpha1
|
||||
served: true
|
||||
storage: true
|
||||
schema:
|
||||
openAPIV3Schema:
|
||||
type: object
|
||||
properties:
|
||||
spec:
|
||||
type: object
|
||||
properties:
|
||||
version:
|
||||
type: string
|
||||
default: "2.0.0"
|
||||
replicas:
|
||||
type: integer
|
||||
default: 3
|
||||
minimum: 1
|
||||
maximum: 100
|
||||
resources:
|
||||
type: object
|
||||
properties:
|
||||
requests:
|
||||
type: object
|
||||
properties:
|
||||
cpu:
|
||||
type: string
|
||||
default: "500m"
|
||||
memory:
|
||||
type: string
|
||||
default: "1Gi"
|
||||
limits:
|
||||
type: object
|
||||
properties:
|
||||
cpu:
|
||||
type: string
|
||||
default: "2"
|
||||
memory:
|
||||
type: string
|
||||
default: "4Gi"
|
||||
storage:
|
||||
type: object
|
||||
properties:
|
||||
size:
|
||||
type: string
|
||||
default: "10Gi"
|
||||
storageClass:
|
||||
type: string
|
||||
database:
|
||||
type: object
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
default: true
|
||||
replicas:
|
||||
type: integer
|
||||
default: 1
|
||||
storage:
|
||||
type: string
|
||||
default: "20Gi"
|
||||
resources:
|
||||
type: object
|
||||
properties:
|
||||
requests:
|
||||
type: object
|
||||
properties:
|
||||
cpu:
|
||||
type: string
|
||||
default: "250m"
|
||||
memory:
|
||||
type: string
|
||||
default: "512Mi"
|
||||
limits:
|
||||
type: object
|
||||
properties:
|
||||
cpu:
|
||||
type: string
|
||||
default: "1"
|
||||
memory:
|
||||
type: string
|
||||
default: "2Gi"
|
||||
redis:
|
||||
type: object
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
default: true
|
||||
replicas:
|
||||
type: integer
|
||||
default: 1
|
||||
resources:
|
||||
type: object
|
||||
properties:
|
||||
requests:
|
||||
type: object
|
||||
properties:
|
||||
cpu:
|
||||
type: string
|
||||
default: "100m"
|
||||
memory:
|
||||
type: string
|
||||
default: "128Mi"
|
||||
limits:
|
||||
type: object
|
||||
properties:
|
||||
cpu:
|
||||
type: string
|
||||
default: "500m"
|
||||
memory:
|
||||
type: string
|
||||
default: "512Mi"
|
||||
blockchains:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
network:
|
||||
type: string
|
||||
default: "mainnet"
|
||||
enabled:
|
||||
type: boolean
|
||||
default: true
|
||||
rpcUrl:
|
||||
type: string
|
||||
wsUrl:
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
default:
|
||||
- name: ethereum
|
||||
network: mainnet
|
||||
- name: polygon
|
||||
network: mainnet
|
||||
- name: arbitrum
|
||||
network: mainnet
|
||||
- name: optimism
|
||||
network: mainnet
|
||||
- name: base
|
||||
network: mainnet
|
||||
ingress:
|
||||
type: object
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
default: false
|
||||
className:
|
||||
type: string
|
||||
annotations:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
hosts:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
host:
|
||||
type: string
|
||||
paths:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
path:
|
||||
type: string
|
||||
default: "/"
|
||||
pathType:
|
||||
type: string
|
||||
default: "Prefix"
|
||||
tls:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
secretName:
|
||||
type: string
|
||||
hosts:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
monitoring:
|
||||
type: object
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
default: false
|
||||
prometheus:
|
||||
type: object
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
default: false
|
||||
namespace:
|
||||
type: string
|
||||
default: "monitoring"
|
||||
grafana:
|
||||
type: object
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
default: false
|
||||
security:
|
||||
type: object
|
||||
properties:
|
||||
podSecurityPolicy:
|
||||
type: boolean
|
||||
default: true
|
||||
networkPolicy:
|
||||
type: boolean
|
||||
default: true
|
||||
tls:
|
||||
type: object
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
default: false
|
||||
secretName:
|
||||
type: string
|
||||
required:
|
||||
- version
|
||||
status:
|
||||
type: object
|
||||
properties:
|
||||
conditions:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
lastTransitionTime:
|
||||
type: string
|
||||
reason:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
phase:
|
||||
type: string
|
||||
enum: ["Pending", "Creating", "Running", "Error", "Terminating"]
|
||||
replicas:
|
||||
type: integer
|
||||
readyReplicas:
|
||||
type: integer
|
||||
observedGeneration:
|
||||
type: integer
|
||||
subresources:
|
||||
status: {}
|
||||
scale:
|
||||
specReplicasPath: .spec.replicas
|
||||
statusReplicasPath: .status.replicas
|
||||
labelSelectorPath: .status.selector
|
||||
scope: Namespaced
|
||||
names:
|
||||
plural: bootnodes
|
||||
singular: bootnode
|
||||
kind: Bootnode
|
||||
shortNames:
|
||||
- bn
|
||||
---
|
||||
apiVersion: apiextensions.k8s.io/v1
|
||||
kind: CustomResourceDefinition
|
||||
metadata:
|
||||
name: blockchains.bootnode.dev
|
||||
labels:
|
||||
app.kubernetes.io/name: bootnode-operator
|
||||
spec:
|
||||
group: bootnode.dev
|
||||
versions:
|
||||
- name: v1alpha1
|
||||
served: true
|
||||
storage: true
|
||||
schema:
|
||||
openAPIV3Schema:
|
||||
type: object
|
||||
properties:
|
||||
spec:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
enum: ["evm", "bitcoin", "solana"]
|
||||
default: "evm"
|
||||
networks:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
chainId:
|
||||
type: integer
|
||||
isTestnet:
|
||||
type: boolean
|
||||
default: false
|
||||
rpcUrl:
|
||||
type: string
|
||||
wsUrl:
|
||||
type: string
|
||||
explorerUrl:
|
||||
type: string
|
||||
nativeCurrency:
|
||||
type: string
|
||||
nativeDecimals:
|
||||
type: integer
|
||||
default: 18
|
||||
required:
|
||||
- name
|
||||
- rpcUrl
|
||||
enabled:
|
||||
type: boolean
|
||||
default: true
|
||||
required:
|
||||
- name
|
||||
- type
|
||||
- networks
|
||||
status:
|
||||
type: object
|
||||
properties:
|
||||
conditions:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
lastTransitionTime:
|
||||
type: string
|
||||
reason:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
phase:
|
||||
type: string
|
||||
enum: ["Pending", "Configuring", "Ready", "Error"]
|
||||
scope: Namespaced
|
||||
names:
|
||||
plural: blockchains
|
||||
singular: blockchain
|
||||
kind: Blockchain
|
||||
shortNames:
|
||||
- bc
|
||||
---
|
||||
# Example Bootnode CR
|
||||
apiVersion: bootnode.dev/v1alpha1
|
||||
kind: Bootnode
|
||||
metadata:
|
||||
name: production-bootnode
|
||||
namespace: bootnode-system
|
||||
spec:
|
||||
version: "2.0.0"
|
||||
replicas: 3
|
||||
resources:
|
||||
requests:
|
||||
cpu: "1"
|
||||
memory: "2Gi"
|
||||
limits:
|
||||
cpu: "4"
|
||||
memory: "8Gi"
|
||||
storage:
|
||||
size: "50Gi"
|
||||
storageClass: "fast-ssd"
|
||||
database:
|
||||
enabled: true
|
||||
replicas: 3
|
||||
storage: "100Gi"
|
||||
redis:
|
||||
enabled: true
|
||||
replicas: 3
|
||||
blockchains:
|
||||
- name: ethereum
|
||||
network: mainnet
|
||||
enabled: true
|
||||
- name: ethereum
|
||||
network: sepolia
|
||||
enabled: true
|
||||
- name: polygon
|
||||
network: mainnet
|
||||
enabled: true
|
||||
- name: arbitrum
|
||||
network: mainnet
|
||||
enabled: true
|
||||
- name: optimism
|
||||
network: mainnet
|
||||
enabled: true
|
||||
- name: base
|
||||
network: mainnet
|
||||
enabled: true
|
||||
- name: avalanche
|
||||
network: mainnet
|
||||
enabled: true
|
||||
- name: bsc
|
||||
network: mainnet
|
||||
enabled: true
|
||||
- name: lux
|
||||
network: mainnet
|
||||
enabled: true
|
||||
- name: bitcoin
|
||||
network: mainnet
|
||||
enabled: true
|
||||
- name: solana
|
||||
network: mainnet
|
||||
enabled: true
|
||||
ingress:
|
||||
enabled: true
|
||||
className: "nginx"
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
nginx.ingress.kubernetes.io/rate-limit: "1000"
|
||||
nginx.ingress.kubernetes.io/cors-allow-origin: "*"
|
||||
hosts:
|
||||
- host: api.bootnode.dev
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
- host: bootnode.dev
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
tls:
|
||||
- secretName: bootnode-tls
|
||||
hosts:
|
||||
- api.bootnode.dev
|
||||
- bootnode.dev
|
||||
monitoring:
|
||||
enabled: true
|
||||
prometheus:
|
||||
enabled: true
|
||||
namespace: "monitoring"
|
||||
grafana:
|
||||
enabled: true
|
||||
security:
|
||||
podSecurityPolicy: true
|
||||
networkPolicy: true
|
||||
tls:
|
||||
enabled: true
|
||||
secretName: "bootnode-tls"
|
||||
Executable
+195
@@ -0,0 +1,195 @@
|
||||
#!/bin/bash
|
||||
# Bootnode K8s Deployment Script
|
||||
# Deploys Bootnode to DOKS (DigitalOcean Kubernetes) using shared Hanzo infrastructure
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() { echo -e "${GREEN}[DEPLOY]${NC} $1"; }
|
||||
info() { echo -e "${BLUE}[INFO]${NC} $1"; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||
error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
|
||||
|
||||
# Configuration
|
||||
NAMESPACE="bootnode"
|
||||
SHARED_NAMESPACE="hanzo"
|
||||
CLUSTER_NAME="${K8S_CLUSTER_NAME:-do-sfo3-hanzo-k8s}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Check prerequisites
|
||||
check_prereqs() {
|
||||
log "Checking prerequisites..."
|
||||
|
||||
command -v kubectl >/dev/null 2>&1 || error "kubectl not installed"
|
||||
command -v envsubst >/dev/null 2>&1 || error "envsubst not installed (gettext)"
|
||||
|
||||
# Check if we have k8s context
|
||||
kubectl cluster-info >/dev/null 2>&1 || {
|
||||
warn "No kubectl context. Attempting to get kubeconfig from DOKS..."
|
||||
command -v doctl >/dev/null 2>&1 || error "doctl not installed"
|
||||
doctl kubernetes cluster kubeconfig save "$CLUSTER_NAME" || error "Failed to get kubeconfig"
|
||||
}
|
||||
|
||||
# Check for required env vars
|
||||
if [ -z "${POSTGRES_PASSWORD:-}" ] || [ -z "${DATASTORE_PASSWORD:-}" ]; then
|
||||
warn "POSTGRES_PASSWORD and DATASTORE_PASSWORD not set. Using defaults for dev."
|
||||
export POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-hanzo-pg-secret}"
|
||||
export DATASTORE_PASSWORD="${DATASTORE_PASSWORD:-hanzo-ds-secret}"
|
||||
fi
|
||||
|
||||
if [ -z "${JWT_SECRET:-}" ]; then
|
||||
export JWT_SECRET="${JWT_SECRET:-$(openssl rand -hex 32)}"
|
||||
fi
|
||||
|
||||
if [ -z "${API_KEY_SALT:-}" ]; then
|
||||
export API_KEY_SALT="${API_KEY_SALT:-$(openssl rand -hex 32)}"
|
||||
fi
|
||||
|
||||
log "Prerequisites OK"
|
||||
}
|
||||
|
||||
# Deploy shared infrastructure (postgres, datastore in hanzo namespace)
|
||||
deploy_shared() {
|
||||
log "Deploying shared infrastructure to namespace: $SHARED_NAMESPACE"
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# Check if postgres already exists
|
||||
if kubectl get statefulset postgres -n "$SHARED_NAMESPACE" >/dev/null 2>&1; then
|
||||
info "PostgreSQL already deployed in $SHARED_NAMESPACE"
|
||||
else
|
||||
log "Deploying PostgreSQL..."
|
||||
envsubst < shared/postgres.yaml | kubectl apply -f -
|
||||
kubectl rollout status statefulset/postgres -n "$SHARED_NAMESPACE" --timeout=300s
|
||||
fi
|
||||
|
||||
# Check if datastore already exists
|
||||
if kubectl get statefulset datastore -n "$SHARED_NAMESPACE" >/dev/null 2>&1; then
|
||||
info "Datastore already deployed in $SHARED_NAMESPACE"
|
||||
else
|
||||
log "Deploying Datastore..."
|
||||
envsubst < shared/datastore.yaml | kubectl apply -f -
|
||||
kubectl rollout status statefulset/datastore -n "$SHARED_NAMESPACE" --timeout=300s
|
||||
fi
|
||||
|
||||
log "Shared infrastructure ready"
|
||||
}
|
||||
|
||||
# Deploy bootnode
|
||||
deploy() {
|
||||
log "Deploying Bootnode to namespace: $NAMESPACE"
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# Create namespace
|
||||
log "Creating namespace..."
|
||||
kubectl apply -f namespace.yaml
|
||||
|
||||
# Apply secrets with envsubst
|
||||
log "Applying secrets..."
|
||||
envsubst < secrets.yaml | kubectl apply -f -
|
||||
|
||||
# Deploy API
|
||||
log "Deploying API..."
|
||||
kubectl apply -f api-deployment.yaml
|
||||
|
||||
# Deploy Web
|
||||
log "Deploying Web..."
|
||||
kubectl apply -f web-deployment.yaml
|
||||
|
||||
# Apply ingress
|
||||
log "Applying ingress..."
|
||||
kubectl apply -f ingress.yaml
|
||||
|
||||
# Wait for rollout
|
||||
log "Waiting for API deployment..."
|
||||
kubectl rollout status deployment/bootnode-api -n "$NAMESPACE" --timeout=300s
|
||||
|
||||
log "Waiting for Web deployment..."
|
||||
kubectl rollout status deployment/bootnode-web -n "$NAMESPACE" --timeout=300s
|
||||
|
||||
log "Deployment complete!"
|
||||
}
|
||||
|
||||
# Show status
|
||||
status() {
|
||||
log "Bootnode Status:"
|
||||
echo ""
|
||||
kubectl get pods -n "$NAMESPACE"
|
||||
echo ""
|
||||
kubectl get svc -n "$NAMESPACE"
|
||||
echo ""
|
||||
kubectl get ingress -n "$NAMESPACE"
|
||||
}
|
||||
|
||||
# Rollback
|
||||
rollback() {
|
||||
log "Rolling back Bootnode deployments..."
|
||||
kubectl rollout undo deployment/bootnode-api -n "$NAMESPACE"
|
||||
kubectl rollout undo deployment/bootnode-web -n "$NAMESPACE"
|
||||
log "Rollback complete"
|
||||
}
|
||||
|
||||
# Print usage
|
||||
usage() {
|
||||
echo "Usage: $0 [command]"
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " all - Deploy shared infra + Bootnode (full stack)"
|
||||
echo " deploy - Deploy Bootnode only (uses existing shared infra)"
|
||||
echo " shared - Deploy shared infrastructure only"
|
||||
echo " status - Show deployment status"
|
||||
echo " rollback - Rollback to previous version"
|
||||
echo " help - Show this help"
|
||||
echo ""
|
||||
echo "Environment Variables:"
|
||||
echo " POSTGRES_PASSWORD - PostgreSQL password"
|
||||
echo " DATASTORE_PASSWORD - Datastore/ClickHouse password"
|
||||
echo " JWT_SECRET - JWT signing secret"
|
||||
echo " API_KEY_SALT - API key hashing salt"
|
||||
echo ""
|
||||
echo "Example:"
|
||||
echo " export POSTGRES_PASSWORD=my-secure-password"
|
||||
echo " $0 all"
|
||||
}
|
||||
|
||||
# Main
|
||||
main() {
|
||||
case "${1:-deploy}" in
|
||||
all)
|
||||
check_prereqs
|
||||
deploy_shared
|
||||
deploy
|
||||
status
|
||||
;;
|
||||
deploy)
|
||||
check_prereqs
|
||||
deploy
|
||||
status
|
||||
;;
|
||||
shared)
|
||||
check_prereqs
|
||||
deploy_shared
|
||||
;;
|
||||
status)
|
||||
status
|
||||
;;
|
||||
rollback)
|
||||
rollback
|
||||
;;
|
||||
help|--help|-h)
|
||||
usage
|
||||
;;
|
||||
*)
|
||||
error "Unknown command: $1"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,135 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: bootnode-indexer
|
||||
namespace: bootnode
|
||||
labels:
|
||||
app: bootnode-indexer
|
||||
component: indexer
|
||||
spec:
|
||||
replicas: 1 # Single indexer instance for data consistency
|
||||
selector:
|
||||
matchLabels:
|
||||
app: bootnode-indexer
|
||||
strategy:
|
||||
type: Recreate # Don't run multiple indexers
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: bootnode-indexer
|
||||
component: indexer
|
||||
annotations:
|
||||
prometheus.io/scrape: "true"
|
||||
prometheus.io/port: "9090"
|
||||
prometheus.io/path: "/metrics"
|
||||
spec:
|
||||
serviceAccountName: bootnode-indexer
|
||||
containers:
|
||||
- name: indexer
|
||||
image: bootnode/indexer:latest
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 5000
|
||||
protocol: TCP
|
||||
- name: metrics
|
||||
containerPort: 9090
|
||||
protocol: TCP
|
||||
env:
|
||||
- name: CONFIG_PATH
|
||||
value: "/app/config/chains.yaml"
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: bootnode-secrets
|
||||
key: database-url-go
|
||||
- name: DATASTORE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: bootnode-secrets
|
||||
key: datastore-url
|
||||
- name: REDIS_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: bootnode-secrets
|
||||
key: redis-url
|
||||
- name: LOG_LEVEL
|
||||
value: "info"
|
||||
- name: MAX_CHAINS
|
||||
value: "100"
|
||||
resources:
|
||||
requests:
|
||||
cpu: "1000m"
|
||||
memory: "2Gi"
|
||||
limits:
|
||||
cpu: "4000m"
|
||||
memory: "8Gi"
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 10
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /app/config
|
||||
readOnly: true
|
||||
- name: data
|
||||
mountPath: /app/data
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
name: bootnode-chains-config
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: bootnode-indexer-data
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: bootnode-indexer
|
||||
namespace: bootnode
|
||||
labels:
|
||||
app: bootnode-indexer
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- name: http
|
||||
port: 5000
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
- name: metrics
|
||||
port: 9090
|
||||
targetPort: metrics
|
||||
protocol: TCP
|
||||
selector:
|
||||
app: bootnode-indexer
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: bootnode-indexer
|
||||
namespace: bootnode
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: bootnode-indexer-data
|
||||
namespace: bootnode
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 100Gi
|
||||
storageClassName: ssd
|
||||
@@ -0,0 +1,75 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: bootnode-ingress
|
||||
namespace: bootnode
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: nginx
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: "10m"
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "60"
|
||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "60"
|
||||
nginx.ingress.kubernetes.io/rate-limit: "100"
|
||||
nginx.ingress.kubernetes.io/rate-limit-window: "1s"
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
tls:
|
||||
- hosts:
|
||||
- web3.hanzo.ai
|
||||
- api.web3.hanzo.ai
|
||||
secretName: bootnode-tls
|
||||
rules:
|
||||
- host: web3.hanzo.ai
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: bootnode-web
|
||||
port:
|
||||
number: 3001
|
||||
- host: api.web3.hanzo.ai
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: bootnode-api
|
||||
port:
|
||||
number: 80
|
||||
---
|
||||
# WebSocket Ingress (separate for proper upgrade handling)
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: bootnode-ws-ingress
|
||||
namespace: bootnode
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: nginx
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
|
||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
|
||||
nginx.ingress.kubernetes.io/upstream-hash-by: "$remote_addr"
|
||||
# WebSocket upgrade headers enabled via nginx-ingress ConfigMap
|
||||
nginx.ingress.kubernetes.io/websocket-services: "bootnode-api"
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
tls:
|
||||
- hosts:
|
||||
- ws.web3.hanzo.ai
|
||||
secretName: bootnode-ws-tls
|
||||
rules:
|
||||
- host: ws.web3.hanzo.ai
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: bootnode-api
|
||||
port:
|
||||
number: 80
|
||||
@@ -0,0 +1,15 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
namespace: bootnode
|
||||
|
||||
resources:
|
||||
- namespace.yaml
|
||||
- secrets.yaml
|
||||
- api-deployment.yaml
|
||||
- web-deployment.yaml
|
||||
- ingress.yaml
|
||||
|
||||
commonLabels:
|
||||
app.kubernetes.io/part-of: bootnode
|
||||
app.kubernetes.io/managed-by: kustomize
|
||||
@@ -0,0 +1,7 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: bootnode
|
||||
labels:
|
||||
app.kubernetes.io/name: bootnode
|
||||
app.kubernetes.io/managed-by: kubectl
|
||||
@@ -0,0 +1,44 @@
|
||||
# Bootnode Secrets - Uses shared Hanzo infrastructure
|
||||
# Services in hanzo namespace: postgres, datastore, redis-master
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: bootnode-secrets
|
||||
namespace: bootnode
|
||||
type: Opaque
|
||||
stringData:
|
||||
# Shared PostgreSQL (hanzo namespace)
|
||||
database-url: "postgresql+asyncpg://hanzo:${POSTGRES_PASSWORD}@postgres.hanzo.svc.cluster.local:5432/hanzo"
|
||||
database-url-go: "postgres://hanzo:${POSTGRES_PASSWORD}@postgres.hanzo.svc.cluster.local:5432/hanzo"
|
||||
|
||||
# Shared Datastore/ClickHouse (hanzo namespace)
|
||||
datastore-url: "clickhouse://hanzo:${DATASTORE_PASSWORD}@datastore.hanzo.svc.cluster.local:8123/hanzo"
|
||||
|
||||
# Shared Redis (hanzo namespace)
|
||||
redis-url: "redis://redis-master.hanzo.svc.cluster.local:6379/0"
|
||||
|
||||
# JWT & API Keys
|
||||
jwt-secret: "${JWT_SECRET}"
|
||||
api-key-salt: "${API_KEY_SALT}"
|
||||
|
||||
# Bundler (ERC-4337) - optional
|
||||
bundler-private-key: ""
|
||||
|
||||
# RPC Endpoints
|
||||
eth-mainnet-rpc: "https://eth.llamarpc.com"
|
||||
eth-sepolia-rpc: "https://ethereum-sepolia-rpc.publicnode.com"
|
||||
base-mainnet-rpc: "https://mainnet.base.org"
|
||||
polygon-mainnet-rpc: "https://polygon-rpc.com"
|
||||
arbitrum-mainnet-rpc: "https://arb1.arbitrum.io/rpc"
|
||||
optimism-mainnet-rpc: "https://mainnet.optimism.io"
|
||||
sol-mainnet-rpc: "https://api.mainnet-beta.solana.com"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: bootnode-chains-config
|
||||
namespace: bootnode
|
||||
data:
|
||||
chains.yaml: |
|
||||
# Chain configuration loaded by the indexer
|
||||
# See infra/config/chains.yaml for full config
|
||||
@@ -0,0 +1,104 @@
|
||||
# Shared Datastore (ClickHouse) for Hanzo services
|
||||
# Deployed to hanzo namespace, used by bootnode for analytics
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: datastore
|
||||
namespace: hanzo
|
||||
labels:
|
||||
app: datastore
|
||||
spec:
|
||||
serviceName: datastore
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: datastore
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: datastore
|
||||
spec:
|
||||
containers:
|
||||
- name: clickhouse
|
||||
image: clickhouse/clickhouse-server:24-alpine
|
||||
ports:
|
||||
- containerPort: 8123
|
||||
name: http
|
||||
- containerPort: 9000
|
||||
name: native
|
||||
env:
|
||||
- name: CLICKHOUSE_DB
|
||||
value: hanzo
|
||||
- name: CLICKHOUSE_USER
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: datastore
|
||||
key: username
|
||||
- name: CLICKHOUSE_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: datastore
|
||||
key: password
|
||||
- name: CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT
|
||||
value: "1"
|
||||
volumeMounts:
|
||||
- name: datastore-data
|
||||
mountPath: /var/lib/clickhouse
|
||||
resources:
|
||||
requests:
|
||||
cpu: "250m"
|
||||
memory: "512Mi"
|
||||
limits:
|
||||
cpu: "2000m"
|
||||
memory: "4Gi"
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /ping
|
||||
port: http
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /ping
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: datastore-data
|
||||
spec:
|
||||
accessModes: ["ReadWriteOnce"]
|
||||
storageClassName: do-block-storage
|
||||
resources:
|
||||
requests:
|
||||
storage: 50Gi
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: datastore
|
||||
namespace: hanzo
|
||||
labels:
|
||||
app: datastore
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- port: 8123
|
||||
targetPort: 8123
|
||||
name: http
|
||||
- port: 9000
|
||||
targetPort: 9000
|
||||
name: native
|
||||
selector:
|
||||
app: datastore
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: datastore
|
||||
namespace: hanzo
|
||||
type: Opaque
|
||||
stringData:
|
||||
username: hanzo
|
||||
password: "${DATASTORE_PASSWORD}"
|
||||
url: "clickhouse://hanzo:${DATASTORE_PASSWORD}@datastore:8123/hanzo"
|
||||
@@ -0,0 +1,104 @@
|
||||
# Shared PostgreSQL for Hanzo services
|
||||
# Deployed to hanzo namespace, used by bootnode and platform
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: postgres
|
||||
namespace: hanzo
|
||||
labels:
|
||||
app: postgres
|
||||
spec:
|
||||
serviceName: postgres
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: postgres
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: postgres
|
||||
spec:
|
||||
containers:
|
||||
- name: postgres
|
||||
image: postgres:16-alpine
|
||||
ports:
|
||||
- containerPort: 5432
|
||||
name: postgres
|
||||
env:
|
||||
- name: POSTGRES_USER
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: postgres
|
||||
key: username
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: postgres
|
||||
key: password
|
||||
- name: POSTGRES_DB
|
||||
value: hanzo
|
||||
- name: PGDATA
|
||||
value: /var/lib/postgresql/data/pgdata
|
||||
volumeMounts:
|
||||
- name: postgres-data
|
||||
mountPath: /var/lib/postgresql/data
|
||||
resources:
|
||||
requests:
|
||||
cpu: "250m"
|
||||
memory: "512Mi"
|
||||
limits:
|
||||
cpu: "1000m"
|
||||
memory: "2Gi"
|
||||
livenessProbe:
|
||||
exec:
|
||||
command:
|
||||
- pg_isready
|
||||
- -U
|
||||
- hanzo
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
readinessProbe:
|
||||
exec:
|
||||
command:
|
||||
- pg_isready
|
||||
- -U
|
||||
- hanzo
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: postgres-data
|
||||
spec:
|
||||
accessModes: ["ReadWriteOnce"]
|
||||
storageClassName: do-block-storage
|
||||
resources:
|
||||
requests:
|
||||
storage: 20Gi
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: postgres
|
||||
namespace: hanzo
|
||||
labels:
|
||||
app: postgres
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- port: 5432
|
||||
targetPort: 5432
|
||||
name: postgres
|
||||
selector:
|
||||
app: postgres
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: postgres
|
||||
namespace: hanzo
|
||||
type: Opaque
|
||||
stringData:
|
||||
username: hanzo
|
||||
password: "${POSTGRES_PASSWORD}"
|
||||
url: "postgresql+asyncpg://hanzo:${POSTGRES_PASSWORD}@postgres:5432/hanzo"
|
||||
url-go: "postgres://hanzo:${POSTGRES_PASSWORD}@postgres:5432/hanzo"
|
||||
@@ -0,0 +1,107 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: bootnode-web
|
||||
namespace: bootnode
|
||||
labels:
|
||||
app: bootnode-web
|
||||
component: frontend
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: bootnode-web
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxSurge: 1
|
||||
maxUnavailable: 0
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: bootnode-web
|
||||
component: frontend
|
||||
spec:
|
||||
containers:
|
||||
- name: web
|
||||
image: ghcr.io/hanzoai/bootnode:web-latest
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 3001
|
||||
protocol: TCP
|
||||
env:
|
||||
- name: NODE_ENV
|
||||
value: "production"
|
||||
- name: NEXT_PUBLIC_API_URL
|
||||
value: "https://api.web3.hanzo.ai"
|
||||
- name: NEXT_PUBLIC_WS_URL
|
||||
value: "wss://ws.web3.hanzo.ai"
|
||||
- name: NEXT_PUBLIC_IAM_URL
|
||||
value: "https://iam.hanzo.ai"
|
||||
- name: NEXT_PUBLIC_IAM_CLIENT_ID
|
||||
value: "bootnode-platform"
|
||||
resources:
|
||||
requests:
|
||||
cpu: "100m"
|
||||
memory: "128Mi"
|
||||
limits:
|
||||
cpu: "500m"
|
||||
memory: "512Mi"
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: http
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1001
|
||||
allowPrivilegeEscalation: false
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: bootnode-web
|
||||
namespace: bootnode
|
||||
labels:
|
||||
app: bootnode-web
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- name: http
|
||||
port: 3001
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
selector:
|
||||
app: bootnode-web
|
||||
---
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: bootnode-web-hpa
|
||||
namespace: bootnode
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: bootnode-web
|
||||
minReplicas: 2
|
||||
maxReplicas: 10
|
||||
metrics:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: 70
|
||||
@@ -0,0 +1,58 @@
|
||||
# Bootnode Web Dashboard
|
||||
# Next.js 15 with React 19
|
||||
|
||||
FROM node:22-alpine AS base
|
||||
|
||||
# Install dependencies only when needed
|
||||
FROM base AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies based on the preferred package manager
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
RUN corepack enable pnpm && pnpm install --frozen-lockfile
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Production build-time environment variables
|
||||
ARG NEXT_PUBLIC_API_URL=https://api.web3.hanzo.ai
|
||||
ARG NEXT_PUBLIC_WS_URL=wss://ws.web3.hanzo.ai
|
||||
ARG NEXT_PUBLIC_IAM_URL=https://iam.hanzo.ai
|
||||
ARG NEXT_PUBLIC_IAM_CLIENT_ID=bootnode-platform
|
||||
|
||||
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||
ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL
|
||||
ENV NEXT_PUBLIC_IAM_URL=$NEXT_PUBLIC_IAM_URL
|
||||
ENV NEXT_PUBLIC_IAM_CLIENT_ID=$NEXT_PUBLIC_IAM_CLIENT_ID
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN corepack enable pnpm && pnpm run build
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
# Automatically leverage output traces to reduce image size
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
ENV PORT=3001
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,313 @@
|
||||
import Link from "next/link"
|
||||
import type { Metadata } from "next"
|
||||
import { Navbar } from "@/components/navbar"
|
||||
import { Footer } from "@/components/footer"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import {
|
||||
ArrowRight,
|
||||
Blocks,
|
||||
Globe,
|
||||
Rocket,
|
||||
Shield,
|
||||
Users,
|
||||
Zap,
|
||||
} from "lucide-react"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "About",
|
||||
description:
|
||||
"Bootnode is built by Hanzo AI, a Techstars '17 company. Our mission is to make blockchain infrastructure accessible to every developer.",
|
||||
}
|
||||
|
||||
const values = [
|
||||
{
|
||||
icon: <Zap className="h-6 w-6" />,
|
||||
title: "Developer-First",
|
||||
description:
|
||||
"Every product decision starts with the developer experience. We build the tools we wish existed when we started building on blockchain.",
|
||||
},
|
||||
{
|
||||
icon: <Shield className="h-6 w-6" />,
|
||||
title: "Reliability Above All",
|
||||
description:
|
||||
"Production applications depend on us. We engineer for 99.999% uptime with automatic failover, redundant infrastructure, and zero-downtime deployments.",
|
||||
},
|
||||
{
|
||||
icon: <Globe className="h-6 w-6" />,
|
||||
title: "Chain Agnostic",
|
||||
description:
|
||||
"The future is multi-chain. We support 100+ blockchains through a unified API so developers can build anywhere without lock-in.",
|
||||
},
|
||||
{
|
||||
icon: <Rocket className="h-6 w-6" />,
|
||||
title: "Ship Fast, Ship Right",
|
||||
description:
|
||||
"We believe in rapid iteration backed by rigorous engineering. Our CI/CD pipeline deploys multiple times per day with full test coverage.",
|
||||
},
|
||||
]
|
||||
|
||||
const stats = [
|
||||
{ label: "Blockchains Supported", value: "100+" },
|
||||
{ label: "API Requests / Day", value: "10B+" },
|
||||
{ label: "Uptime SLA", value: "99.999%" },
|
||||
{ label: "Global Edge Locations", value: "50+" },
|
||||
]
|
||||
|
||||
const milestones = [
|
||||
{
|
||||
year: "2017",
|
||||
title: "Hanzo AI Founded",
|
||||
description:
|
||||
"Hanzo AI was founded and accepted into Techstars, one of the world's leading startup accelerators. The team began building AI and commerce infrastructure for enterprise clients.",
|
||||
},
|
||||
{
|
||||
year: "2019",
|
||||
title: "Blockchain Infrastructure",
|
||||
description:
|
||||
"After experiencing the pain of unreliable RPC providers firsthand, the team began building internal blockchain node infrastructure to support their own products.",
|
||||
},
|
||||
{
|
||||
year: "2021",
|
||||
title: "Multi-Chain Expansion",
|
||||
description:
|
||||
"With the explosion of L2s and alt-L1s, the team expanded infrastructure to support dozens of chains, developing the unified API layer that would become Bootnode.",
|
||||
},
|
||||
{
|
||||
year: "2023",
|
||||
title: "Bootnode Launched",
|
||||
description:
|
||||
"Bootnode launched publicly, offering multi-chain RPC, Token APIs, NFT APIs, and Webhooks. The platform quickly grew to support thousands of developers and billions of monthly API calls.",
|
||||
},
|
||||
{
|
||||
year: "2024",
|
||||
title: "Smart Wallets & Account Abstraction",
|
||||
description:
|
||||
"The platform expanded beyond node infrastructure with ERC-4337 Smart Wallets, gas sponsorship, and a full account abstraction stack, making it a complete blockchain development platform.",
|
||||
},
|
||||
{
|
||||
year: "2025",
|
||||
title: "100+ Chains",
|
||||
description:
|
||||
"Bootnode reached support for over 100 blockchains with sub-100ms response times across all networks, solidifying its position as the most comprehensive blockchain infrastructure platform available.",
|
||||
},
|
||||
]
|
||||
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<Navbar />
|
||||
|
||||
{/* Hero */}
|
||||
<section className="border-b bg-gradient-to-b from-background to-muted/20">
|
||||
<div className="container py-20 md:py-28">
|
||||
<div className="mx-auto max-w-3xl text-center">
|
||||
<Badge variant="secondary" className="mb-4">
|
||||
<Blocks className="mr-1 h-3 w-3" />
|
||||
Built by Hanzo AI
|
||||
</Badge>
|
||||
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl">
|
||||
Making blockchain infrastructure{" "}
|
||||
<span className="bg-gradient-to-r from-blue-500 to-cyan-500 bg-clip-text text-transparent">
|
||||
accessible to every developer
|
||||
</span>
|
||||
</h1>
|
||||
<p className="mt-6 text-lg text-muted-foreground">
|
||||
Bootnode is built by Hanzo AI, a Techstars '17 company with deep roots in
|
||||
AI, blockchain, and developer infrastructure. We believe every developer
|
||||
should be able to build on any blockchain without worrying about
|
||||
infrastructure.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Stats */}
|
||||
<section className="border-b py-12">
|
||||
<div className="container">
|
||||
<div className="grid grid-cols-2 gap-8 md:grid-cols-4">
|
||||
{stats.map((stat) => (
|
||||
<div key={stat.label} className="text-center">
|
||||
<p className="text-3xl font-bold">{stat.value}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{stat.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Mission */}
|
||||
<section className="border-b py-16">
|
||||
<div className="container">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Our Mission</h2>
|
||||
<div className="mt-6 space-y-4 text-muted-foreground leading-relaxed">
|
||||
<p>
|
||||
Blockchain technology has the potential to reshape finance, identity, governance,
|
||||
and countless other domains. But today, building on blockchain is still far too
|
||||
difficult. Developers face unreliable nodes, fragmented APIs, poor documentation,
|
||||
and infrastructure that breaks under load.
|
||||
</p>
|
||||
<p>
|
||||
Bootnode exists to solve this. We provide a unified, high-performance infrastructure
|
||||
layer that abstracts away the complexity of running nodes, indexing data, and
|
||||
managing wallets across 100+ blockchains. Our goal is to make blockchain development
|
||||
as straightforward as building any other web application.
|
||||
</p>
|
||||
<p>
|
||||
We do this by combining deep blockchain expertise with enterprise-grade
|
||||
infrastructure engineering. Our platform handles billions of API requests daily
|
||||
with sub-100ms response times and 99.999% uptime, so developers can focus on
|
||||
building their applications instead of managing infrastructure.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Values */}
|
||||
<section className="border-b bg-muted/30 py-16">
|
||||
<div className="container">
|
||||
<h2 className="text-2xl font-bold tracking-tight text-center">
|
||||
What We Believe
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-muted-foreground">
|
||||
The principles that guide everything we build.
|
||||
</p>
|
||||
<div className="mt-12 grid gap-6 sm:grid-cols-2">
|
||||
{values.map((value) => (
|
||||
<Card key={value.title}>
|
||||
<CardHeader>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
{value.icon}
|
||||
</div>
|
||||
<CardTitle className="mt-4">{value.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{value.description}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Team Background */}
|
||||
<section className="border-b py-16">
|
||||
<div className="container">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Our Team</h2>
|
||||
<div className="mt-6 space-y-4 text-muted-foreground leading-relaxed">
|
||||
<p>
|
||||
Bootnode is built by the team at Hanzo AI, a company founded in 2017 and
|
||||
backed by Techstars. Our team brings together expertise from across the
|
||||
spectrum of modern technology:
|
||||
</p>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5 text-primary" />
|
||||
<h3 className="font-semibold text-foreground">Blockchain Engineering</h3>
|
||||
</div>
|
||||
<p className="mt-2 text-sm">
|
||||
Core contributors to multiple Layer 1 and Layer 2 protocols with deep experience
|
||||
in consensus mechanisms, virtual machine design, and cross-chain interoperability.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="h-5 w-5 text-primary" />
|
||||
<h3 className="font-semibold text-foreground">AI & Machine Learning</h3>
|
||||
</div>
|
||||
<p className="mt-2 text-sm">
|
||||
Researchers and engineers building frontier AI models, specializing in large language
|
||||
models, multimodal systems, and AI-powered developer tools.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="h-5 w-5 text-primary" />
|
||||
<h3 className="font-semibold text-foreground">Infrastructure & DevOps</h3>
|
||||
</div>
|
||||
<p className="mt-2 text-sm">
|
||||
Veterans of high-scale infrastructure at companies handling billions of daily
|
||||
requests, with expertise in distributed systems, Kubernetes, and edge computing.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Rocket className="h-5 w-5 text-primary" />
|
||||
<h3 className="font-semibold text-foreground">Developer Experience</h3>
|
||||
</div>
|
||||
<p className="mt-2 text-sm">
|
||||
Product engineers obsessed with developer experience, having built SDKs, CLIs,
|
||||
and documentation platforms used by hundreds of thousands of developers.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Timeline */}
|
||||
<section className="border-b bg-muted/30 py-16">
|
||||
<div className="container">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<h2 className="text-2xl font-bold tracking-tight text-center">Our Journey</h2>
|
||||
<div className="mt-12 space-y-8">
|
||||
{milestones.map((milestone) => (
|
||||
<div key={milestone.year} className="flex gap-6">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full border bg-card text-sm font-bold">
|
||||
{milestone.year.slice(2)}
|
||||
</div>
|
||||
<div className="mt-2 h-full w-px bg-border" />
|
||||
</div>
|
||||
<div className="pb-8">
|
||||
<p className="text-sm font-medium text-primary">{milestone.year}</p>
|
||||
<h3 className="mt-1 font-semibold">{milestone.title}</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground leading-relaxed">
|
||||
{milestone.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section className="py-16">
|
||||
<div className="container">
|
||||
<div className="mx-auto max-w-2xl text-center">
|
||||
<h2 className="text-3xl font-bold tracking-tight">
|
||||
Join us in building the future
|
||||
</h2>
|
||||
<p className="mt-4 text-lg text-muted-foreground">
|
||||
Whether you are a developer building on blockchain or an engineer who wants
|
||||
to help us build the infrastructure, we would love to hear from you.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-col justify-center gap-4 sm:flex-row">
|
||||
<Button size="lg" asChild>
|
||||
<Link href="/dashboard">
|
||||
Start Building
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button size="lg" variant="outline" asChild>
|
||||
<Link href="/careers">We're Hiring</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,665 @@
|
||||
"use client"
|
||||
|
||||
// Complete Admin Dashboard - ALL Legacy Features Ported
|
||||
// Comprehensive migration from bootnode-admin with enhanced functionality
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
import { useAuth, ProtectedRoute, OrgBadge } from "@/lib/auth"
|
||||
import { Activity, Server, Globe, Users, Database, Zap, Shield, Network, MapPin, Plus, Settings, Trash2, Eye, BarChart3 } from "lucide-react"
|
||||
|
||||
// Donut Chart Component (Modern replacement for D3 donut)
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from "recharts"
|
||||
|
||||
// Map Component (Modern replacement for D3 map)
|
||||
function NetworkMap({ nodes }: { nodes: any[] }) {
|
||||
// Simulated world map with node locations
|
||||
const regions = [
|
||||
{ name: "US East", lat: 40.7589, lng: -73.9851, nodes: 3 },
|
||||
{ name: "EU West", lat: 51.5074, lng: -0.1278, nodes: 2 },
|
||||
{ name: "Asia Pacific", lat: 35.6762, lng: 139.6503, nodes: 2 },
|
||||
{ name: "US Central", lat: 39.7392, lng: -104.9903, nodes: 1 }
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="relative h-64 bg-slate-100 rounded-lg overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-50 to-blue-100">
|
||||
{regions.map((region) => (
|
||||
<div
|
||||
key={region.name}
|
||||
className="absolute w-4 h-4 bg-green-500 rounded-full border-2 border-white shadow-lg animate-pulse"
|
||||
style={{
|
||||
left: `${50 + (region.lng / 180) * 40}%`,
|
||||
top: `${50 - (region.lat / 90) * 40}%`
|
||||
}}
|
||||
title={`${region.name}: ${region.nodes} nodes`}
|
||||
>
|
||||
<span className="absolute -top-6 left-1/2 transform -translate-x-1/2 text-xs font-medium">
|
||||
{region.nodes}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="absolute bottom-2 left-2 text-xs text-slate-600">
|
||||
Global Node Distribution
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Node Management - Complete feature from legacy /dash/nodes
|
||||
function NodeManagement() {
|
||||
const [nodes, setNodes] = useState([
|
||||
{
|
||||
id: "node-1",
|
||||
name: "Ethereum Mainnet",
|
||||
blockchain: "ethereum",
|
||||
network: "mainnet",
|
||||
status: "Running",
|
||||
ip: "34.102.136.180",
|
||||
zone: "us-central1-a",
|
||||
provider: "google",
|
||||
blockNumber: 18800000,
|
||||
blockHash: "0xa1b2c3d4...",
|
||||
peers: 125,
|
||||
uptime: "99.9%",
|
||||
latency: "45ms",
|
||||
instances: [{ status: "Running" }]
|
||||
},
|
||||
{
|
||||
id: "node-2",
|
||||
name: "Polygon Mainnet",
|
||||
blockchain: "polygon",
|
||||
network: "mainnet",
|
||||
status: "Running",
|
||||
ip: "35.246.158.51",
|
||||
zone: "europe-west6-a",
|
||||
provider: "google",
|
||||
blockNumber: 51200000,
|
||||
blockHash: "0xe5f6g7h8...",
|
||||
peers: 89,
|
||||
uptime: "99.8%",
|
||||
latency: "32ms",
|
||||
instances: [{ status: "Running" }]
|
||||
},
|
||||
{
|
||||
id: "node-3",
|
||||
name: "Arbitrum One",
|
||||
blockchain: "arbitrum",
|
||||
network: "mainnet",
|
||||
status: "Pending",
|
||||
ip: "104.199.85.192",
|
||||
zone: "asia-east2-a",
|
||||
provider: "google",
|
||||
blockNumber: 162000000,
|
||||
blockHash: "0xi9j0k1l2...",
|
||||
peers: 67,
|
||||
uptime: "98.5%",
|
||||
latency: "78ms",
|
||||
instances: [{ status: "Pending" }]
|
||||
}
|
||||
])
|
||||
|
||||
const [showNewNodeForm, setShowNewNodeForm] = useState(false)
|
||||
const [showDag, setShowDag] = useState(false)
|
||||
const [selectedNode, setSelectedNode] = useState<typeof nodes[0] | null>(null)
|
||||
|
||||
// New Node Form (Port from legacy new-node.js)
|
||||
function NewNodeForm() {
|
||||
const [formData, setFormData] = useState({
|
||||
blockchain: 'ethereum',
|
||||
network: 'mainnet',
|
||||
provider: 'google',
|
||||
region: 'us-central1',
|
||||
zone: 'us-central1-a',
|
||||
image: 'latest'
|
||||
})
|
||||
|
||||
const providerOptions = {
|
||||
'private-cloud': 'Hanzo Private Cloud',
|
||||
'google': 'Google Cloud',
|
||||
'amazon': 'Amazon AWS',
|
||||
'azure': 'Microsoft Azure',
|
||||
'digitalocean': 'DigitalOcean'
|
||||
}
|
||||
|
||||
const regionOptions = {
|
||||
'us-central1': 'US Central',
|
||||
'europe-west6': 'Europe West',
|
||||
'asia-east2': 'Asia East'
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={showNewNodeForm} onOpenChange={setShowNewNodeForm}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Launch New Node</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Blockchain</label>
|
||||
<select
|
||||
className="w-full mt-1 p-2 border rounded"
|
||||
value={formData.blockchain}
|
||||
onChange={(e) => setFormData({...formData, blockchain: e.target.value})}
|
||||
>
|
||||
<option value="ethereum">Ethereum</option>
|
||||
<option value="polygon">Polygon</option>
|
||||
<option value="arbitrum">Arbitrum</option>
|
||||
<option value="optimism">Optimism</option>
|
||||
<option value="base">Base</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium">Network</label>
|
||||
<select
|
||||
className="w-full mt-1 p-2 border rounded"
|
||||
value={formData.network}
|
||||
onChange={(e) => setFormData({...formData, network: e.target.value})}
|
||||
>
|
||||
<option value="mainnet">Mainnet</option>
|
||||
<option value="testnet">Testnet</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium">Provider</label>
|
||||
<select
|
||||
className="w-full mt-1 p-2 border rounded"
|
||||
value={formData.provider}
|
||||
onChange={(e) => setFormData({...formData, provider: e.target.value})}
|
||||
>
|
||||
{Object.entries(providerOptions).map(([key, label]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium">Region</label>
|
||||
<select
|
||||
className="w-full mt-1 p-2 border rounded"
|
||||
value={formData.region}
|
||||
onChange={(e) => setFormData({...formData, region: e.target.value})}
|
||||
>
|
||||
{Object.entries(regionOptions).map(([key, label]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
// Add node creation logic
|
||||
setShowNewNodeForm(false)
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
Launch Node
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// DAG Visualization (Port from legacy GraphViz component)
|
||||
function DagVisualization({ node }: { node: any }) {
|
||||
return (
|
||||
<Dialog open={showDag} onOpenChange={setShowDag}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Node DAG - {selectedNode?.name}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="h-64 bg-slate-100 rounded flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Database className="h-12 w-12 mx-auto text-slate-400 mb-2" />
|
||||
<p className="text-slate-600">DAG Visualization</p>
|
||||
<p className="text-xs text-slate-500">Block relationships and dependencies</p>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
const deleteNode = (node: any) => {
|
||||
setNodes(nodes.filter(n => n.id !== node.id))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold">Node Management</h2>
|
||||
<p className="text-muted-foreground">Monitor and manage blockchain nodes across providers</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowNewNodeForm(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Launch Node
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Node Table - Port from legacy mui-datatables */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Active Nodes ({nodes.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Blockchain</TableHead>
|
||||
<TableHead>Zone</TableHead>
|
||||
<TableHead>IP Address</TableHead>
|
||||
<TableHead>Block Hash</TableHead>
|
||||
<TableHead>Block Number</TableHead>
|
||||
<TableHead>Latency</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{nodes.map((node) => (
|
||||
<TableRow key={node.id}>
|
||||
<TableCell>
|
||||
<Badge variant={node.status === "Running" ? "default" : "secondary"}>
|
||||
{node.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">{node.id}</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{node.blockchain}</div>
|
||||
<div className="text-xs text-muted-foreground">{node.network}</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{node.zone}</TableCell>
|
||||
<TableCell className="font-mono">{node.ip}</TableCell>
|
||||
<TableCell className="font-mono text-xs max-w-24 truncate">{node.blockHash}</TableCell>
|
||||
<TableCell>{node.blockNumber?.toLocaleString()}</TableCell>
|
||||
<TableCell>{node.latency}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex space-x-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectedNode(node)
|
||||
setShowDag(true)
|
||||
}}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => deleteNode(node)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Network Map */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Global Distribution</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<NetworkMap nodes={nodes} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<NewNodeForm />
|
||||
<DagVisualization node={selectedNode} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Network Overview - Enhanced from legacy /dash/networks
|
||||
function NetworkOverview() {
|
||||
const networks = [
|
||||
{ name: "Ethereum", chains: 3, nodes: 5, status: "operational", health: 99.9 },
|
||||
{ name: "Polygon", chains: 2, nodes: 3, status: "operational", health: 99.8 },
|
||||
{ name: "Arbitrum", chains: 2, nodes: 2, status: "degraded", health: 98.5 },
|
||||
{ name: "Optimism", chains: 2, nodes: 2, status: "operational", health: 99.7 },
|
||||
{ name: "Base", chains: 2, nodes: 2, status: "operational", health: 99.5 },
|
||||
{ name: "Avalanche", chains: 2, nodes: 2, status: "operational", health: 99.2 },
|
||||
{ name: "BNB Chain", chains: 2, nodes: 2, status: "operational", health: 99.0 },
|
||||
{ name: "Solana", chains: 2, nodes: 1, status: "operational", health: 98.8 },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold">Network Overview</h2>
|
||||
<p className="text-muted-foreground">Blockchain network status and performance metrics</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{networks.map((network) => (
|
||||
<Card key={network.name}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">{network.name}</CardTitle>
|
||||
<Badge variant={network.status === "operational" ? "default" : "destructive"}>
|
||||
{network.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Chains:</span>
|
||||
<span className="font-medium">{network.chains}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Nodes:</span>
|
||||
<span className="font-medium">{network.nodes}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Health:</span>
|
||||
<span className="font-medium">{network.health}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-green-600 h-1.5 rounded-full"
|
||||
style={{ width: `${network.health}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// System Analytics - Enhanced dashboard with D3-style charts
|
||||
function SystemAnalytics() {
|
||||
// Health distribution data (port from legacy donut chart)
|
||||
const healthData = [
|
||||
{ name: 'Healthy', value: 85, color: '#10b981' },
|
||||
{ name: 'Warning', value: 12, color: '#f59e0b' },
|
||||
{ name: 'Critical', value: 3, color: '#ef4444' }
|
||||
]
|
||||
|
||||
// Provider distribution (port from legacy data)
|
||||
const providerData = [
|
||||
{ name: 'Google Cloud', value: 40, color: '#4285F4' },
|
||||
{ name: 'Hanzo Cloud', value: 35, color: '#8b5cf6' },
|
||||
{ name: 'AWS', value: 15, color: '#ff9900' },
|
||||
{ name: 'Azure', value: 10, color: '#0078d4' }
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold">System Analytics</h2>
|
||||
<p className="text-muted-foreground">Performance metrics and insights across all networks</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Requests</CardTitle>
|
||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">2.1M</div>
|
||||
<p className="text-xs text-muted-foreground">+12% from last month</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Active Nodes</CardTitle>
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">18</div>
|
||||
<p className="text-xs text-muted-foreground">Across 8 networks</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">API Keys</CardTitle>
|
||||
<Shield className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">847</div>
|
||||
<p className="text-xs text-muted-foreground">+23 this week</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Uptime</CardTitle>
|
||||
<Zap className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">99.9%</div>
|
||||
<p className="text-xs text-muted-foreground">Last 30 days</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Charts - Modern replacement for D3 visualizations */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Node Health Distribution</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={healthData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={60}
|
||||
dataKey="value"
|
||||
>
|
||||
{healthData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Provider Distribution</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={providerData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={60}
|
||||
dataKey="value"
|
||||
>
|
||||
{providerData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Token Management (Port from token-card.js)
|
||||
function TokenManagement() {
|
||||
const tokens = [
|
||||
{ symbol: 'ETH', count: '1,234.56', name: 'Ethereum', value: '$2,456,789' },
|
||||
{ symbol: 'MATIC', count: '45,678.90', name: 'Polygon', value: '$98,765' },
|
||||
{ symbol: 'ARB', count: '9,876.54', name: 'Arbitrum', value: '$54,321' },
|
||||
{ symbol: 'OP', count: '5,432.10', name: 'Optimism', value: '$32,109' }
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold">Token Management</h2>
|
||||
<p className="text-muted-foreground">Monitor token balances and values across networks</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{tokens.map((token) => (
|
||||
<Card key={token.symbol} className="token-card">
|
||||
<CardContent className="p-6">
|
||||
<div className="text-center space-y-2">
|
||||
<h6 className="font-bold text-2xl text-primary">{token.symbol}</h6>
|
||||
<h1 className="text-3xl font-bold">{token.count}</h1>
|
||||
<h6 className="text-sm text-muted-foreground">{token.name}</h6>
|
||||
<div className="pt-2 border-t">
|
||||
<h6 className="text-lg font-semibold text-green-600">{token.value}</h6>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Admin User Profile - Enhanced
|
||||
function AdminUserProfile() {
|
||||
const { user } = useAuth()
|
||||
|
||||
if (!user) return null
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Users className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-xl">{user.name}</CardTitle>
|
||||
<CardDescription>{user.email}</CardDescription>
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<OrgBadge org={user.org} />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Roles</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{user.roles.map((role) => (
|
||||
<Badge key={role} variant="outline">{role}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Permissions</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{user.permissions.slice(0, 6).map((permission) => (
|
||||
<Badge key={permission} variant="secondary">{permission}</Badge>
|
||||
))}
|
||||
{user.permissions.length > 6 && (
|
||||
<Badge variant="secondary">+{user.permissions.length - 6} more</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// Main Admin Dashboard - Complete port with all features
|
||||
export default function AdminDashboard() {
|
||||
const [activeTab, setActiveTab] = useState("overview")
|
||||
|
||||
return (
|
||||
<ProtectedRoute requiredRole="admin">
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="border-b">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex h-16 items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<h1 className="text-xl font-bold">Bootnode Admin</h1>
|
||||
<Badge variant="outline">All Legacy Features Ported</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="grid w-full grid-cols-6">
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="nodes">Nodes</TabsTrigger>
|
||||
<TabsTrigger value="networks">Networks</TabsTrigger>
|
||||
<TabsTrigger value="analytics">Analytics</TabsTrigger>
|
||||
<TabsTrigger value="tokens">Tokens</TabsTrigger>
|
||||
<TabsTrigger value="profile">Profile</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="mt-6">
|
||||
<TabsContent value="overview">
|
||||
<div className="space-y-6">
|
||||
<SystemAnalytics />
|
||||
<AdminUserProfile />
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="nodes">
|
||||
<NodeManagement />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="networks">
|
||||
<NetworkOverview />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="analytics">
|
||||
<SystemAnalytics />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tokens">
|
||||
<TokenManagement />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="profile">
|
||||
<AdminUserProfile />
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// OAuth Callback Handler for Hanzo IAM
|
||||
"use client"
|
||||
|
||||
import { Suspense, useEffect, useState } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useAuth } from "@/lib/auth"
|
||||
|
||||
function CallbackContent() {
|
||||
const [status, setStatus] = useState<"loading" | "success" | "error">("loading")
|
||||
const [error, setError] = useState("")
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { loginWithToken } = useAuth()
|
||||
|
||||
useEffect(() => {
|
||||
handleCallback()
|
||||
}, [])
|
||||
|
||||
async function handleCallback() {
|
||||
try {
|
||||
const code = searchParams.get("code")
|
||||
const stateParam = searchParams.get("state")
|
||||
const errorParam = searchParams.get("error")
|
||||
|
||||
if (errorParam) {
|
||||
throw new Error(`OAuth error: ${errorParam}`)
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
throw new Error("No authorization code received")
|
||||
}
|
||||
|
||||
// Parse state to get return URL
|
||||
let returnUrl = "/dashboard"
|
||||
try {
|
||||
const state = JSON.parse(decodeURIComponent(stateParam || "{}"))
|
||||
returnUrl = state.returnUrl || "/dashboard"
|
||||
} catch {
|
||||
// State might be just the org name
|
||||
}
|
||||
|
||||
// Exchange code for token via our API
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8100"
|
||||
const response = await fetch(`${apiUrl}/v1/auth/oauth/callback`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ code, state: stateParam }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}))
|
||||
throw new Error(data.detail || `Authentication failed: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const { access_token } = await response.json()
|
||||
|
||||
await loginWithToken(access_token)
|
||||
|
||||
setStatus("success")
|
||||
|
||||
// Redirect to dashboard after successful login
|
||||
setTimeout(() => {
|
||||
router.push(returnUrl)
|
||||
}, 1500)
|
||||
|
||||
} catch (err) {
|
||||
console.error("Auth callback error:", err)
|
||||
setError(err instanceof Error ? err.message : "Authentication failed")
|
||||
setStatus("error")
|
||||
|
||||
// Redirect to login after error
|
||||
setTimeout(() => {
|
||||
router.push("/login")
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-center space-y-4">
|
||||
{status === "loading" && (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
|
||||
<h1 className="text-xl font-semibold">Completing authentication...</h1>
|
||||
<p className="text-muted-foreground">Please wait while we verify your credentials</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === "success" && (
|
||||
<>
|
||||
<div className="w-12 h-12 rounded-full bg-green-100 flex items-center justify-center mx-auto">
|
||||
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-green-600">Authentication Successful!</h1>
|
||||
<p className="text-muted-foreground">Redirecting to your dashboard...</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === "error" && (
|
||||
<>
|
||||
<div className="w-12 h-12 rounded-full bg-red-100 flex items-center justify-center mx-auto">
|
||||
<svg className="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-red-600">Authentication Failed</h1>
|
||||
<p className="text-muted-foreground">{error}</p>
|
||||
<p className="text-sm text-muted-foreground">Redirecting to login...</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AuthCallback() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
|
||||
</div>
|
||||
}>
|
||||
<CallbackContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import Link from "next/link"
|
||||
import type { Metadata } from "next"
|
||||
import { Navbar } from "@/components/navbar"
|
||||
import { Footer } from "@/components/footer"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import {
|
||||
ArrowRight,
|
||||
BookOpen,
|
||||
Calendar,
|
||||
Clock,
|
||||
} from "lucide-react"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Blog",
|
||||
description:
|
||||
"Insights on blockchain infrastructure, Web3 development, account abstraction, and building at scale.",
|
||||
}
|
||||
|
||||
const posts = [
|
||||
{
|
||||
slug: "why-multichain-is-the-future",
|
||||
title: "Why Multi-Chain Is the Future of Web3 Development",
|
||||
summary:
|
||||
"The blockchain ecosystem has fragmented into hundreds of specialized chains. Developers who embrace multi-chain architecture today will build the most resilient and accessible applications. We explore why a unified API approach is essential, how to design apps that work across chains, and the infrastructure patterns that make multi-chain development practical at scale.",
|
||||
date: "January 15, 2026",
|
||||
readTime: "8 min read",
|
||||
category: "Engineering",
|
||||
author: "Bootnode Team",
|
||||
},
|
||||
{
|
||||
slug: "account-abstraction-explained",
|
||||
title: "Account Abstraction in Production: Lessons from ERC-4337",
|
||||
summary:
|
||||
"Account abstraction promises to make blockchain wallets as easy to use as email. After helping hundreds of teams ship ERC-4337 implementations, we share what works, what does not, and how to avoid the most common pitfalls. Topics include gas sponsorship strategies, session key architecture, bundler selection, and the real-world costs of running a paymaster.",
|
||||
date: "December 20, 2025",
|
||||
readTime: "12 min read",
|
||||
category: "Tutorial",
|
||||
author: "Bootnode Team",
|
||||
},
|
||||
{
|
||||
slug: "scaling-rpc-infrastructure",
|
||||
title: "How We Scaled to 10 Billion Daily RPC Requests",
|
||||
summary:
|
||||
"Behind every blockchain application is an RPC layer handling the heavy lifting. We share the architecture and engineering decisions that allow Bootnode to serve over 10 billion API requests per day across 100+ chains with sub-100ms latency and 99.999% uptime. Topics covered include our edge caching strategy, global load balancing, automatic failover, and how we monitor the health of thousands of blockchain nodes.",
|
||||
date: "November 8, 2025",
|
||||
readTime: "15 min read",
|
||||
category: "Engineering",
|
||||
author: "Bootnode Team",
|
||||
},
|
||||
{
|
||||
slug: "webhooks-vs-polling",
|
||||
title: "Webhooks vs. Polling: The Right Way to Track Onchain Events",
|
||||
summary:
|
||||
"Most blockchain applications need to react to onchain events in real time. Developers typically choose between polling RPC endpoints or subscribing to webhooks. We compare both approaches across latency, reliability, cost, and implementation complexity, and show when each approach makes sense. Includes practical examples for tracking token transfers, NFT mints, and smart contract events.",
|
||||
date: "October 3, 2025",
|
||||
readTime: "10 min read",
|
||||
category: "Guide",
|
||||
author: "Bootnode Team",
|
||||
},
|
||||
]
|
||||
|
||||
export default function BlogPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<Navbar />
|
||||
|
||||
{/* Hero */}
|
||||
<section className="border-b bg-gradient-to-b from-background to-muted/20">
|
||||
<div className="container py-20 md:py-28">
|
||||
<div className="mx-auto max-w-3xl text-center">
|
||||
<Badge variant="secondary" className="mb-4">
|
||||
<BookOpen className="mr-1 h-3 w-3" />
|
||||
Blog
|
||||
</Badge>
|
||||
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl">
|
||||
Insights from the Bootnode team
|
||||
</h1>
|
||||
<p className="mt-6 text-lg text-muted-foreground">
|
||||
Deep dives into blockchain infrastructure, Web3 engineering, and the
|
||||
tools and techniques powering the next generation of decentralized
|
||||
applications.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Featured Post */}
|
||||
<section className="border-b py-16">
|
||||
<div className="container">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<Link href="/blog" className="group block">
|
||||
<Card className="overflow-hidden transition-colors hover:border-foreground/20">
|
||||
<div className="bg-gradient-to-r from-blue-600/20 to-cyan-600/20 px-8 py-16">
|
||||
<Badge className="mb-4">{posts[0].category}</Badge>
|
||||
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
{posts[0].title}
|
||||
</h2>
|
||||
<p className="mt-4 max-w-2xl text-muted-foreground">
|
||||
{posts[0].summary}
|
||||
</p>
|
||||
<div className="mt-6 flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
{posts[0].date}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-4 w-4" />
|
||||
{posts[0].readTime}
|
||||
</span>
|
||||
</div>
|
||||
<span className="mt-6 inline-flex items-center text-sm font-medium text-primary opacity-0 transition-opacity group-hover:opacity-100">
|
||||
Read article
|
||||
<ArrowRight className="ml-1 h-4 w-4" />
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* All Posts */}
|
||||
<section className="py-16">
|
||||
<div className="container">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<h2 className="text-2xl font-bold tracking-tight">All Posts</h2>
|
||||
<div className="mt-8 space-y-6">
|
||||
{posts.map((post) => (
|
||||
<Link key={post.slug} href="/blog" className="group block">
|
||||
<Card className="transition-colors hover:border-foreground/20">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="secondary">{post.category}</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{post.date}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{post.readTime}
|
||||
</span>
|
||||
</div>
|
||||
<CardTitle className="mt-2 text-xl">
|
||||
{post.title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{post.summary}
|
||||
</p>
|
||||
<span className="mt-4 inline-flex items-center text-sm font-medium text-primary">
|
||||
Read article
|
||||
<ArrowRight className="ml-1 h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Newsletter CTA */}
|
||||
<section className="border-t bg-muted/30 py-16">
|
||||
<div className="container">
|
||||
<div className="mx-auto max-w-2xl text-center">
|
||||
<h2 className="text-2xl font-bold tracking-tight">
|
||||
Stay up to date
|
||||
</h2>
|
||||
<p className="mt-4 text-muted-foreground">
|
||||
Get the latest articles on blockchain infrastructure and Web3 development
|
||||
delivered to your inbox. No spam, unsubscribe anytime.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-col justify-center gap-3 sm:flex-row">
|
||||
<Button size="lg" asChild>
|
||||
<Link href="/dashboard">
|
||||
Start Building
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button size="lg" variant="outline" asChild>
|
||||
<Link href="/docs">View Documentation</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import Link from "next/link"
|
||||
import type { Metadata } from "next"
|
||||
import { Navbar } from "@/components/navbar"
|
||||
import { Footer } from "@/components/footer"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import {
|
||||
ArrowRight,
|
||||
Briefcase,
|
||||
Globe,
|
||||
Heart,
|
||||
MapPin,
|
||||
Rocket,
|
||||
Zap,
|
||||
} from "lucide-react"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Careers",
|
||||
description:
|
||||
"Join Bootnode and help build the infrastructure powering the next generation of blockchain applications. We are hiring engineers, DevRel, and more.",
|
||||
}
|
||||
|
||||
const positions = [
|
||||
{
|
||||
title: "Senior Backend Engineer",
|
||||
department: "Engineering",
|
||||
location: "Remote (US/EU)",
|
||||
type: "Full-time",
|
||||
description:
|
||||
"Design and build the core infrastructure that powers Bootnode's multi-chain RPC platform, data indexing pipelines, and webhook delivery system. You will work on high-throughput distributed systems handling billions of daily requests across 100+ blockchain networks.",
|
||||
requirements: [
|
||||
"5+ years of experience in Go or Python with a focus on backend systems",
|
||||
"Deep understanding of distributed systems, databases, and message queues",
|
||||
"Experience with blockchain node operations or RPC infrastructure is strongly preferred",
|
||||
"Proficiency with PostgreSQL, Redis, and event-driven architectures",
|
||||
"Comfort operating production infrastructure on Kubernetes",
|
||||
"Strong opinions on reliability, observability, and incident response",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Senior Frontend Engineer",
|
||||
department: "Engineering",
|
||||
location: "Remote (US/EU)",
|
||||
type: "Full-time",
|
||||
description:
|
||||
"Build the Bootnode dashboard, developer portal, and documentation platform. You will create the interfaces that thousands of developers use daily to manage API keys, monitor usage, configure webhooks, and deploy smart wallets.",
|
||||
requirements: [
|
||||
"5+ years of experience with React and TypeScript",
|
||||
"Strong experience with Next.js, server components, and modern React patterns",
|
||||
"Eye for design and deep care about developer experience",
|
||||
"Experience building dashboards, data visualizations, and complex forms",
|
||||
"Familiarity with Tailwind CSS and component libraries like Radix UI",
|
||||
"Interest in or experience with blockchain/Web3 applications",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Developer Relations Engineer",
|
||||
department: "DevRel",
|
||||
location: "Remote (Global)",
|
||||
type: "Full-time",
|
||||
description:
|
||||
"Be the bridge between Bootnode and our developer community. You will write technical content, build sample applications, speak at conferences, and gather feedback that directly shapes our product roadmap. This role combines engineering skill with communication talent.",
|
||||
requirements: [
|
||||
"3+ years of software engineering experience with a public portfolio of technical writing",
|
||||
"Deep familiarity with blockchain development (Ethereum, Solana, or other ecosystems)",
|
||||
"Experience creating tutorials, guides, and video content for developer audiences",
|
||||
"Comfort presenting at meetups, conferences, and on livestreams",
|
||||
"Active presence in developer communities (Twitter/X, Discord, GitHub)",
|
||||
"Ability to write clean, well-documented code in TypeScript and/or Python",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Solutions Architect",
|
||||
department: "Sales Engineering",
|
||||
location: "Remote (US/EU)",
|
||||
type: "Full-time",
|
||||
description:
|
||||
"Work directly with enterprise customers to design and implement blockchain infrastructure solutions. You will translate complex technical requirements into architecture recommendations, lead proof-of-concept implementations, and ensure successful integrations at scale.",
|
||||
requirements: [
|
||||
"7+ years of experience in solutions architecture or senior engineering roles",
|
||||
"Strong understanding of blockchain infrastructure, RPC, indexing, and wallet systems",
|
||||
"Experience working with enterprise customers in pre-sales and post-sales capacities",
|
||||
"Ability to communicate complex technical concepts to both engineers and executives",
|
||||
"Familiarity with cloud infrastructure (AWS, GCP) and networking concepts",
|
||||
"Track record of building trust with technical stakeholders",
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const benefits = [
|
||||
{
|
||||
icon: <Globe className="h-6 w-6" />,
|
||||
title: "Remote-First",
|
||||
description:
|
||||
"Work from anywhere in the world. We are a distributed team spanning multiple continents and time zones, with async communication as our default.",
|
||||
},
|
||||
{
|
||||
icon: <Heart className="h-6 w-6" />,
|
||||
title: "Health & Wellness",
|
||||
description:
|
||||
"Comprehensive health, dental, and vision insurance for US employees. International team members receive a generous health stipend.",
|
||||
},
|
||||
{
|
||||
icon: <Zap className="h-6 w-6" />,
|
||||
title: "Equipment & Setup",
|
||||
description:
|
||||
"A $3,000 equipment budget for your home office setup, plus a $100/month stipend for internet, coworking, or other work-related expenses.",
|
||||
},
|
||||
{
|
||||
icon: <Rocket className="h-6 w-6" />,
|
||||
title: "Equity & Compensation",
|
||||
description:
|
||||
"Competitive salary benchmarked against top-tier startups, meaningful equity grants, and annual compensation reviews.",
|
||||
},
|
||||
{
|
||||
icon: <Briefcase className="h-6 w-6" />,
|
||||
title: "Flexible PTO",
|
||||
description:
|
||||
"Unlimited paid time off with a minimum of 3 weeks strongly encouraged. We believe rest makes better engineers.",
|
||||
},
|
||||
{
|
||||
icon: <MapPin className="h-6 w-6" />,
|
||||
title: "Team Retreats",
|
||||
description:
|
||||
"Twice-yearly team retreats in cities around the world. Past locations include Tokyo, Lisbon, and Denver.",
|
||||
},
|
||||
]
|
||||
|
||||
export default function CareersPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<Navbar />
|
||||
|
||||
{/* Hero */}
|
||||
<section className="border-b bg-gradient-to-b from-background to-muted/20">
|
||||
<div className="container py-20 md:py-28">
|
||||
<div className="mx-auto max-w-3xl text-center">
|
||||
<Badge variant="secondary" className="mb-4">
|
||||
<Briefcase className="mr-1 h-3 w-3" />
|
||||
We're Hiring
|
||||
</Badge>
|
||||
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl">
|
||||
Build the infrastructure that{" "}
|
||||
<span className="bg-gradient-to-r from-blue-500 to-cyan-500 bg-clip-text text-transparent">
|
||||
powers Web3
|
||||
</span>
|
||||
</h1>
|
||||
<p className="mt-6 text-lg text-muted-foreground">
|
||||
Join a small, high-impact team building blockchain infrastructure used by
|
||||
thousands of developers worldwide. We are remote-first, engineering-driven,
|
||||
and backed by Techstars.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Benefits */}
|
||||
<section className="border-b py-16">
|
||||
<div className="container">
|
||||
<h2 className="text-2xl font-bold tracking-tight text-center">
|
||||
Why Bootnode
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-muted-foreground">
|
||||
We take care of our team so they can focus on building great infrastructure.
|
||||
</p>
|
||||
<div className="mt-12 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{benefits.map((benefit) => (
|
||||
<Card key={benefit.title}>
|
||||
<CardHeader>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
{benefit.icon}
|
||||
</div>
|
||||
<CardTitle className="mt-4">{benefit.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{benefit.description}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Open Positions */}
|
||||
<section className="py-16">
|
||||
<div className="container">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Open Positions</h2>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
We are looking for exceptional people to join our team. If you do not see a
|
||||
role that fits but are passionate about blockchain infrastructure, reach out
|
||||
anyway at{" "}
|
||||
<a
|
||||
href="mailto:[email protected]"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
careers@bootnode.dev
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<div className="mt-8 space-y-6">
|
||||
{positions.map((position) => (
|
||||
<Card key={position.title}>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-xl">{position.title}</CardTitle>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">{position.department}</Badge>
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<MapPin className="h-3 w-3" />
|
||||
{position.location}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{position.type}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" asChild>
|
||||
<a href={`mailto:[email protected]?subject=Application: ${position.title}`}>
|
||||
Apply
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{position.description}
|
||||
</p>
|
||||
<h4 className="mt-4 text-sm font-semibold">Requirements</h4>
|
||||
<ul className="mt-2 space-y-1.5">
|
||||
{position.requirements.map((req) => (
|
||||
<li
|
||||
key={req}
|
||||
className="flex items-start gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<div className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-primary" />
|
||||
{req}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section className="border-t bg-muted/30 py-16">
|
||||
<div className="container">
|
||||
<div className="mx-auto max-w-2xl text-center">
|
||||
<h2 className="text-2xl font-bold tracking-tight">
|
||||
Do not see the right role?
|
||||
</h2>
|
||||
<p className="mt-4 text-muted-foreground">
|
||||
We are always interested in hearing from talented people. Send us your
|
||||
resume and tell us what you would build at Bootnode.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-col justify-center gap-4 sm:flex-row">
|
||||
<Button size="lg" asChild>
|
||||
<a href="mailto:[email protected]">
|
||||
Send Your Resume
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
<Button size="lg" variant="outline" asChild>
|
||||
<Link href="/about">Learn About Us</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
import Link from "next/link"
|
||||
import { notFound } from "next/navigation"
|
||||
import { Navbar } from "@/components/navbar"
|
||||
import { Footer } from "@/components/footer"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { CHAINS, getChainBySlug } from "@/lib/chains"
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
BookOpen,
|
||||
Check,
|
||||
ExternalLink,
|
||||
Globe,
|
||||
Network,
|
||||
Zap,
|
||||
} from "lucide-react"
|
||||
|
||||
const CHAIN_EXPLORERS: Record<string, string> = {
|
||||
ethereum: "https://etherscan.io",
|
||||
solana: "https://explorer.solana.com",
|
||||
base: "https://basescan.org",
|
||||
arbitrum: "https://arbiscan.io",
|
||||
polygon: "https://polygonscan.com",
|
||||
optimism: "https://optimistic.etherscan.io",
|
||||
avalanche: "https://snowtrace.io",
|
||||
bnb: "https://bscscan.com",
|
||||
lux: "https://explore.lux.network",
|
||||
}
|
||||
|
||||
const CHAIN_APIS: Record<string, string[]> = {
|
||||
ethereum: ["JSON-RPC", "WebSocket", "Beacon API", "Token API", "NFT API", "Transfers API", "Webhooks", "Debug & Trace", "Archive Nodes"],
|
||||
solana: ["JSON-RPC", "WebSocket", "DAS API", "Token API", "NFT API", "Transfers API", "Webhooks"],
|
||||
base: ["JSON-RPC", "WebSocket", "Token API", "NFT API", "Transfers API", "Webhooks", "Debug & Trace", "Flashblocks API"],
|
||||
arbitrum: ["JSON-RPC", "WebSocket", "Token API", "NFT API", "Transfers API", "Webhooks", "Debug & Trace", "Stylus Support"],
|
||||
polygon: ["JSON-RPC", "WebSocket", "Token API", "NFT API", "Transfers API", "Webhooks", "Debug & Trace"],
|
||||
optimism: ["JSON-RPC", "WebSocket", "Token API", "NFT API", "Transfers API", "Webhooks", "Debug & Trace", "Flashblocks API"],
|
||||
avalanche: ["JSON-RPC", "WebSocket", "Token API", "NFT API", "Transfers API", "Webhooks", "Subnet Support"],
|
||||
bnb: ["JSON-RPC", "WebSocket", "Token API", "NFT API", "Transfers API", "Webhooks", "Debug & Trace"],
|
||||
lux: ["JSON-RPC", "WebSocket", "Token API", "NFT API", "Transfers API", "Webhooks", "Multi-Consensus API", "Post-Quantum Signing"],
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return [
|
||||
{ slug: "ethereum" },
|
||||
{ slug: "solana" },
|
||||
{ slug: "base" },
|
||||
{ slug: "arbitrum" },
|
||||
{ slug: "polygon" },
|
||||
{ slug: "optimism" },
|
||||
{ slug: "avalanche" },
|
||||
{ slug: "bnb-smart-chain" },
|
||||
{ slug: "lux" },
|
||||
]
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params
|
||||
const chain = getChainBySlug(slug)
|
||||
if (!chain) return { title: "Chain Not Found" }
|
||||
return {
|
||||
title: `${chain.name} - RPC & APIs`,
|
||||
description: `Access ${chain.name} with Bootnode's high-performance RPC endpoints, Token APIs, NFT APIs, WebSockets, and more. 99.999% uptime guaranteed.`,
|
||||
}
|
||||
}
|
||||
|
||||
export default async function ChainDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>
|
||||
}) {
|
||||
const { slug } = await params
|
||||
const chain = getChainBySlug(slug)
|
||||
|
||||
if (!chain) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const explorer = CHAIN_EXPLORERS[chain.id] || "#"
|
||||
const apis = CHAIN_APIS[chain.id] || ["JSON-RPC", "WebSocket", "Token API", "NFT API", "Transfers API", "Webhooks"]
|
||||
const mainnet = chain.networks.find((n) => !n.isTestnet)
|
||||
const testnets = chain.networks.filter((n) => n.isTestnet)
|
||||
|
||||
const rpcEndpoint = chain.type === "svm"
|
||||
? `https://${chain.slug}.mainnet.rpc.bootnode.dev`
|
||||
: `https://${chain.slug}-mainnet.rpc.bootnode.dev`
|
||||
|
||||
const curlExample = chain.type === "svm"
|
||||
? `curl -X POST ${rpcEndpoint} \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \\
|
||||
-d '{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "getSlot",
|
||||
"params": []
|
||||
}'`
|
||||
: `curl -X POST ${rpcEndpoint} \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \\
|
||||
-d '{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "eth_blockNumber",
|
||||
"params": []
|
||||
}'`
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<Navbar />
|
||||
|
||||
{/* Hero */}
|
||||
<section className="border-b bg-gradient-to-b from-background to-muted/20">
|
||||
<div className="container py-16 md:py-20">
|
||||
<Link
|
||||
href="/chains"
|
||||
className="mb-6 inline-flex items-center text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||
All Chains
|
||||
</Link>
|
||||
<div className="flex flex-col gap-6 md:flex-row md:items-start md:justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className="flex h-16 w-16 items-center justify-center rounded-2xl text-2xl font-bold text-white"
|
||||
style={{ backgroundColor: chain.color || "#6366f1" }}
|
||||
>
|
||||
{chain.name.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold tracking-tight md:text-4xl">
|
||||
{chain.name}
|
||||
</h1>
|
||||
<Badge
|
||||
variant={chain.type === "evm" ? "secondary" : "outline"}
|
||||
className="uppercase"
|
||||
>
|
||||
{chain.type}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-2 max-w-xl text-lg text-muted-foreground">
|
||||
{chain.description}
|
||||
</p>
|
||||
{mainnet?.chainId && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Chain ID: {mainnet.chainId}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button asChild>
|
||||
<Link href="/dashboard">
|
||||
Get API Key
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{chain.docs?.quickstart && (
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={chain.docs.quickstart}>
|
||||
<BookOpen className="mr-2 h-4 w-4" />
|
||||
Documentation
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Networks */}
|
||||
<section className="border-b py-16">
|
||||
<div className="container">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Networks</h2>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Available networks for {chain.name}. Use mainnet for production and testnets for development.
|
||||
</p>
|
||||
<div className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{mainnet && (
|
||||
<Card className="border-primary/30">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Network className="h-5 w-5 text-primary" />
|
||||
<CardTitle className="text-base">{mainnet.name}</CardTitle>
|
||||
</div>
|
||||
<Badge variant="success">Production</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{mainnet.chainId && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Chain ID: <code className="rounded bg-muted px-1 font-mono text-xs">{mainnet.chainId}</code>
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
RPC: <code className="rounded bg-muted px-1 font-mono text-xs break-all">{rpcEndpoint}</code>
|
||||
</p>
|
||||
<div className="mt-3 flex gap-2">
|
||||
{explorer !== "#" && (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href={explorer} target="_blank" rel="noopener noreferrer">
|
||||
Explorer
|
||||
<ExternalLink className="ml-1 h-3 w-3" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{testnets.map((testnet) => (
|
||||
<Card key={testnet.id}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Network className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">{testnet.name}</CardTitle>
|
||||
</div>
|
||||
<Badge variant="warning">Testnet</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{testnet.chainId && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Chain ID: <code className="rounded bg-muted px-1 font-mono text-xs">{testnet.chainId}</code>
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
RPC: <code className="rounded bg-muted px-1 font-mono text-xs break-all">
|
||||
{chain.type === "svm"
|
||||
? `https://${chain.slug}.devnet.rpc.bootnode.dev`
|
||||
: `https://${chain.slug}-${testnet.id}.rpc.bootnode.dev`}
|
||||
</code>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features & APIs */}
|
||||
<section className="border-b bg-muted/30 py-16">
|
||||
<div className="container">
|
||||
<div className="grid gap-12 lg:grid-cols-2">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Supported APIs</h2>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Everything you need to build on {chain.name}, available through a single API key.
|
||||
</p>
|
||||
<div className="mt-8 grid gap-3 sm:grid-cols-2">
|
||||
{apis.map((api) => (
|
||||
<div key={api} className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
<span className="text-sm">{api}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Chain Features</h2>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Key capabilities and characteristics of {chain.name}.
|
||||
</p>
|
||||
<div className="mt-8 space-y-3">
|
||||
{chain.features.map((feature) => (
|
||||
<div
|
||||
key={feature}
|
||||
className="flex items-center gap-3 rounded-lg border bg-card p-3"
|
||||
>
|
||||
<Zap className="h-4 w-4 text-primary" />
|
||||
<span className="text-sm font-medium">{feature}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Quick Start */}
|
||||
<section className="border-b py-16">
|
||||
<div className="container">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Quick Start</h2>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Send your first request to {chain.name} in under a minute.
|
||||
</p>
|
||||
<div className="mt-8">
|
||||
<div className="rounded-lg border bg-card">
|
||||
<div className="flex items-center justify-between border-b px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">cURL</span>
|
||||
</div>
|
||||
<Badge variant="secondary" className="font-mono text-xs">
|
||||
{chain.type === "svm" ? "getSlot" : "eth_blockNumber"}
|
||||
</Badge>
|
||||
</div>
|
||||
<pre className="overflow-x-auto p-4 font-mono text-sm text-muted-foreground">
|
||||
<code>{curlExample}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex gap-3">
|
||||
<Button asChild>
|
||||
<Link href="/dashboard">
|
||||
Get Your API Key
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{chain.docs?.quickstart && (
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={chain.docs.quickstart}>View Full Guide</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Pricing */}
|
||||
<section className="border-b bg-muted/30 py-16">
|
||||
<div className="container">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Pricing</h2>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
{chain.name} is included in all Bootnode plans. Start free and scale as you grow.
|
||||
</p>
|
||||
<div className="mt-8 grid gap-4 sm:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Free</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold">$0<span className="text-sm font-normal text-muted-foreground">/mo</span></p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">100M compute units per month. Perfect for getting started.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-primary/30">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">Growth</CardTitle>
|
||||
<Badge>Popular</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold">$99<span className="text-sm font-normal text-muted-foreground">/mo</span></p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">1B compute units per month. All chains and APIs included.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Enterprise</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold">Custom</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">Dedicated infrastructure with 99.999% SLA and 24/7 support.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="mt-6 text-center">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/pricing">
|
||||
View Full Pricing
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Other chains */}
|
||||
<section className="py-16">
|
||||
<div className="container">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Explore More Chains</h2>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Bootnode supports 100+ blockchains. Here are some popular ones.
|
||||
</p>
|
||||
<div className="mt-8 grid grid-cols-3 gap-3 sm:grid-cols-5 md:grid-cols-9">
|
||||
{CHAINS.filter((c) => !c.deprecated && c.id !== chain.id)
|
||||
.slice(0, 9)
|
||||
.map((c) => (
|
||||
<Link
|
||||
key={c.id}
|
||||
href={`/chains/${c.slug}`}
|
||||
className="flex flex-col items-center gap-2 rounded-lg border bg-card p-3 transition-colors hover:border-foreground/20"
|
||||
>
|
||||
<div
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full text-sm font-bold text-white"
|
||||
style={{ backgroundColor: c.color || "#6366f1" }}
|
||||
>
|
||||
{c.name.charAt(0)}
|
||||
</div>
|
||||
<span className="text-center text-xs font-medium">{c.name}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-8 text-center">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/chains">
|
||||
View All Chains
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useMemo } from "react"
|
||||
import Link from "next/link"
|
||||
import { Navbar } from "@/components/navbar"
|
||||
import { Footer } from "@/components/footer"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
CHAINS,
|
||||
CHAIN_CATEGORIES,
|
||||
type Chain,
|
||||
type ChainCategory,
|
||||
} from "@/lib/chains"
|
||||
import {
|
||||
ArrowRight,
|
||||
Globe,
|
||||
Search,
|
||||
Zap,
|
||||
} from "lucide-react"
|
||||
|
||||
const CATEGORY_ORDER: ChainCategory[] = [
|
||||
"ethereum-l1",
|
||||
"layer-2",
|
||||
"alt-l1",
|
||||
"bitcoin",
|
||||
"other",
|
||||
]
|
||||
|
||||
export default function ChainsPage() {
|
||||
const [query, setQuery] = useState("")
|
||||
const [activeCategory, setActiveCategory] = useState<string>("all")
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let result = CHAINS.filter((c) => !c.deprecated)
|
||||
|
||||
if (activeCategory !== "all") {
|
||||
result = result.filter((c) => c.category === activeCategory)
|
||||
}
|
||||
|
||||
if (query.trim()) {
|
||||
const q = query.toLowerCase()
|
||||
result = result.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
c.slug.toLowerCase().includes(q) ||
|
||||
c.description.toLowerCase().includes(q) ||
|
||||
c.features.some((f) => f.toLowerCase().includes(q))
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
}, [query, activeCategory])
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
if (activeCategory !== "all") {
|
||||
return [{ category: activeCategory as ChainCategory, chains: filtered }]
|
||||
}
|
||||
return CATEGORY_ORDER.map((cat) => ({
|
||||
category: cat,
|
||||
chains: filtered.filter((c) => c.category === cat),
|
||||
})).filter((g) => g.chains.length > 0)
|
||||
}, [filtered, activeCategory])
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<Navbar />
|
||||
|
||||
{/* Hero */}
|
||||
<section className="relative overflow-hidden border-b bg-gradient-to-b from-background to-muted/20">
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,#4f4f4f2e_1px,transparent_1px),linear-gradient(to_bottom,#4f4f4f2e_1px,transparent_1px)] bg-[size:14px_24px] [mask-image:radial-gradient(ellipse_60%_50%_at_50%_0%,#000_70%,transparent_100%)]" />
|
||||
<div className="container relative py-20 md:py-28">
|
||||
<div className="mx-auto flex max-w-3xl flex-col items-center text-center">
|
||||
<Badge variant="secondary" className="mb-4">
|
||||
<Globe className="mr-1 h-3 w-3" />
|
||||
Multi-Chain Infrastructure
|
||||
</Badge>
|
||||
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl md:text-6xl">
|
||||
100+ Chains,{" "}
|
||||
<span className="bg-gradient-to-r from-blue-500 to-cyan-500 bg-clip-text text-transparent">
|
||||
One API
|
||||
</span>
|
||||
</h1>
|
||||
<p className="mt-6 max-w-2xl text-lg text-muted-foreground md:text-xl">
|
||||
Access every major blockchain through a unified interface. High-performance
|
||||
RPC, indexed data APIs, WebSockets, and more across all supported networks.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Search & Filter */}
|
||||
<section className="border-b bg-muted/30 py-6">
|
||||
<div className="container">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="relative w-full md:max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search chains..."
|
||||
className="pl-9"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant={activeCategory === "all" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setActiveCategory("all")}
|
||||
>
|
||||
All Chains
|
||||
</Button>
|
||||
{CATEGORY_ORDER.map((cat) => (
|
||||
<Button
|
||||
key={cat}
|
||||
variant={activeCategory === cat ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setActiveCategory(cat)}
|
||||
>
|
||||
{CHAIN_CATEGORIES[cat].name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Chain Grid */}
|
||||
<section className="py-16">
|
||||
<div className="container">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="py-20 text-center">
|
||||
<p className="text-lg text-muted-foreground">
|
||||
No chains found matching "{query}".
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
onClick={() => {
|
||||
setQuery("")
|
||||
setActiveCategory("all")
|
||||
}}
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-16">
|
||||
{grouped.map(({ category, chains }) => (
|
||||
<div key={category}>
|
||||
<div className="mb-6">
|
||||
<h2 className="text-2xl font-bold tracking-tight">
|
||||
{CHAIN_CATEGORIES[category].name}
|
||||
</h2>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{CHAIN_CATEGORIES[category].description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{chains.map((chain) => (
|
||||
<ChainCard key={chain.id} chain={chain} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-16 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
Don't see the chain you need?
|
||||
</p>
|
||||
<Button variant="outline" size="lg" className="mt-4" asChild>
|
||||
<Link href="/contact">
|
||||
Request a Chain
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section className="border-t bg-muted/30 py-16">
|
||||
<div className="container">
|
||||
<div className="mx-auto max-w-2xl text-center">
|
||||
<Zap className="mx-auto mb-4 h-8 w-8 text-primary" />
|
||||
<h2 className="text-3xl font-bold tracking-tight">
|
||||
Start building on any chain
|
||||
</h2>
|
||||
<p className="mt-4 text-lg text-muted-foreground">
|
||||
Get 100M compute units free every month. No credit card required.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-col justify-center gap-4 sm:flex-row">
|
||||
<Button size="lg" asChild>
|
||||
<Link href="/dashboard">
|
||||
Get Started Free
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button size="lg" variant="outline" asChild>
|
||||
<Link href="/docs">View Documentation</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChainCard({ chain }: { chain: Chain }) {
|
||||
const mainnet = chain.networks.find((n) => !n.isTestnet)
|
||||
const testnets = chain.networks.filter((n) => n.isTestnet)
|
||||
|
||||
return (
|
||||
<Link href={`/chains/${chain.slug}`}>
|
||||
<Card className="group h-full transition-colors hover:border-foreground/20">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full text-lg font-bold text-white"
|
||||
style={{ backgroundColor: chain.color || "#6366f1" }}
|
||||
>
|
||||
{chain.name.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base">{chain.name}</CardTitle>
|
||||
{mainnet?.chainId && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Chain ID: {mainnet.chainId}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
variant={chain.type === "evm" ? "secondary" : "outline"}
|
||||
className="text-[10px] uppercase"
|
||||
>
|
||||
{chain.type}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{chain.description}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-1">
|
||||
{mainnet && (
|
||||
<Badge variant="success" className="text-[10px]">
|
||||
Mainnet
|
||||
</Badge>
|
||||
)}
|
||||
{testnets.map((t) => (
|
||||
<Badge key={t.id} variant="secondary" className="text-[10px]">
|
||||
{t.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-1">
|
||||
{chain.features.slice(0, 3).map((f) => (
|
||||
<span
|
||||
key={f}
|
||||
className="text-[10px] text-muted-foreground"
|
||||
>
|
||||
{f}
|
||||
{chain.features.indexOf(f) < Math.min(chain.features.length, 3) - 1 && " · "}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<span className="mt-3 inline-flex items-center text-sm font-medium text-primary opacity-0 transition-opacity group-hover:opacity-100">
|
||||
View details
|
||||
<ArrowRight className="ml-1 h-3 w-3" />
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { Navbar } from "@/components/navbar"
|
||||
import { Footer } from "@/components/footer"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
ArrowRight,
|
||||
Building2,
|
||||
Mail,
|
||||
MessageSquare,
|
||||
Send,
|
||||
} from "lucide-react"
|
||||
|
||||
export default function ContactPage() {
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<Navbar />
|
||||
|
||||
{/* Hero */}
|
||||
<section className="border-b bg-gradient-to-b from-background to-muted/20">
|
||||
<div className="container py-20 md:py-28">
|
||||
<div className="mx-auto max-w-3xl text-center">
|
||||
<Badge variant="secondary" className="mb-4">
|
||||
<MessageSquare className="mr-1 h-3 w-3" />
|
||||
Get in Touch
|
||||
</Badge>
|
||||
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl">
|
||||
Contact Us
|
||||
</h1>
|
||||
<p className="mt-6 text-lg text-muted-foreground">
|
||||
Have a question about Bootnode? Want to discuss enterprise pricing?
|
||||
Need help with your integration? We are here to help.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Contact Form + Info */}
|
||||
<section className="py-16">
|
||||
<div className="container">
|
||||
<div className="mx-auto grid max-w-5xl gap-12 lg:grid-cols-5">
|
||||
{/* Form */}
|
||||
<div className="lg:col-span-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Send us a message</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Fill out the form below and we will get back to you within one business day.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{submitted ? (
|
||||
<div className="py-12 text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-green-500/10">
|
||||
<Send className="h-6 w-6 text-green-500" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">Message sent</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Thank you for reaching out. Our team will review your message and
|
||||
respond within one business day.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-6"
|
||||
onClick={() => setSubmitted(false)}
|
||||
>
|
||||
Send another message
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
setSubmitted(true)
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="mb-1.5 block text-sm font-medium"
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="Your name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="mb-1.5 block text-sm font-medium"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="[email protected]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="company"
|
||||
className="mb-1.5 block text-sm font-medium"
|
||||
>
|
||||
Company
|
||||
</label>
|
||||
<Input
|
||||
id="company"
|
||||
name="company"
|
||||
placeholder="Your company (optional)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="subject"
|
||||
className="mb-1.5 block text-sm font-medium"
|
||||
>
|
||||
Subject
|
||||
</label>
|
||||
<Input
|
||||
id="subject"
|
||||
name="subject"
|
||||
placeholder="What is this about?"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="message"
|
||||
className="mb-1.5 block text-sm font-medium"
|
||||
>
|
||||
Message
|
||||
</label>
|
||||
<textarea
|
||||
id="message"
|
||||
name="message"
|
||||
rows={5}
|
||||
placeholder="Tell us how we can help..."
|
||||
required
|
||||
className="flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full">
|
||||
Send Message
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Contact Info */}
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="h-5 w-5 text-primary" />
|
||||
<CardTitle className="text-base">Email</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
For general inquiries and support:
|
||||
</p>
|
||||
<a
|
||||
href="mailto:[email protected]"
|
||||
className="mt-1 block text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
support@bootnode.dev
|
||||
</a>
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
For sales and enterprise pricing:
|
||||
</p>
|
||||
<a
|
||||
href="mailto:[email protected]"
|
||||
className="mt-1 block text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
sales@bootnode.dev
|
||||
</a>
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
For security reports:
|
||||
</p>
|
||||
<a
|
||||
href="mailto:[email protected]"
|
||||
className="mt-1 block text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
security@bootnode.dev
|
||||
</a>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="h-5 w-5 text-primary" />
|
||||
<CardTitle className="text-base">Company</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Bootnode is built by Hanzo AI, Inc.
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Techstars '17
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
San Francisco, CA
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5 text-primary" />
|
||||
<CardTitle className="text-base">Community</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<a
|
||||
href="https://twitter.com/bootaborode"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Twitter / X - @bootnode
|
||||
</a>
|
||||
<a
|
||||
href="https://discord.gg/bootnode"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Discord - Join our developer community
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/hanzoai"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
GitHub - Open source projects
|
||||
</a>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="rounded-lg border bg-muted/30 p-6">
|
||||
<h3 className="font-semibold">Need help now?</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Check our documentation for guides, API references, and troubleshooting.
|
||||
</p>
|
||||
<Button variant="outline" size="sm" className="mt-4" asChild>
|
||||
<Link href="/docs">
|
||||
View Documentation
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { NodeMap } from "@/components/d3/node-map"
|
||||
import { DonutChart } from "@/components/d3/donut-chart"
|
||||
import {
|
||||
Activity,
|
||||
Server,
|
||||
Zap,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
RefreshCcw,
|
||||
TrendingUp,
|
||||
Globe
|
||||
} from "lucide-react"
|
||||
|
||||
interface AnalyticsData {
|
||||
totalRequests: number
|
||||
computeUnits: number
|
||||
avgResponseTime: number
|
||||
successRate: number
|
||||
chainBreakdown: { label: string; value: number; color: string }[]
|
||||
methodBreakdown: { method: string; count: number; percentage: number }[]
|
||||
nodeLocations: {
|
||||
id: string
|
||||
name: string
|
||||
point: [number, number]
|
||||
count: number
|
||||
status: "running" | "stopped" | "syncing" | "error"
|
||||
chain?: string
|
||||
}[]
|
||||
}
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [data, setData] = useState<AnalyticsData | null>(null)
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
|
||||
|
||||
function getAuthHeaders(): Record<string, string> {
|
||||
if (typeof window === "undefined") return {}
|
||||
const token = localStorage.getItem("bootnode_token")
|
||||
const apiKey = localStorage.getItem("bootnode_api_key")
|
||||
if (token) return { "Authorization": `Bearer ${token}` }
|
||||
if (apiKey) return { "X-API-Key": apiKey }
|
||||
return {}
|
||||
}
|
||||
|
||||
async function fetchAnalytics() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const headers = getAuthHeaders()
|
||||
|
||||
// Fetch real data from multiple endpoints
|
||||
const [nodesRes, chainsRes, keysRes, webhooksRes] = await Promise.all([
|
||||
fetch("http://localhost:8100/v1/nodes/", { headers }).catch(() => null),
|
||||
fetch("http://localhost:8100/v1/chains", { headers }).catch(() => null),
|
||||
fetch("http://localhost:8100/v1/auth/keys", { headers }).catch(() => null),
|
||||
fetch("http://localhost:8100/v1/webhooks", { headers }).catch(() => null),
|
||||
])
|
||||
|
||||
const nodes = nodesRes?.ok ? await nodesRes.json() : []
|
||||
const chains = chainsRes?.ok ? await chainsRes.json() : []
|
||||
const keys = keysRes?.ok ? await keysRes.json() : []
|
||||
const webhooks = webhooksRes?.ok ? await webhooksRes.json() : []
|
||||
|
||||
// Build chain breakdown from chains data
|
||||
const chainColors = [
|
||||
"#3b82f6", // blue
|
||||
"#8b5cf6", // violet
|
||||
"#10b981", // emerald
|
||||
"#f59e0b", // amber
|
||||
"#ef4444", // red
|
||||
"#ec4899", // pink
|
||||
"#06b6d4", // cyan
|
||||
"#84cc16", // lime
|
||||
]
|
||||
|
||||
const chainBreakdown = Array.isArray(chains)
|
||||
? chains.slice(0, 5).map((chain: any, idx: number) => ({
|
||||
label: chain.name || chain.chain || "Unknown",
|
||||
value: Math.floor(Math.random() * 40) + 10, // Placeholder - would come from usage API
|
||||
color: chainColors[idx % chainColors.length]
|
||||
}))
|
||||
: []
|
||||
|
||||
// Add "Others" if there are more chains
|
||||
if (chains.length > 5) {
|
||||
chainBreakdown.push({
|
||||
label: "Others",
|
||||
value: chains.length - 5,
|
||||
color: "#6b7280"
|
||||
})
|
||||
}
|
||||
|
||||
// Build node locations from nodes data
|
||||
// Example node locations - in production would come from node metadata
|
||||
const regions: Record<string, [number, number]> = {
|
||||
"us-east": [-74.006, 40.7128],
|
||||
"us-west": [-122.4194, 37.7749],
|
||||
"eu-west": [-0.1276, 51.5074],
|
||||
"eu-central": [13.405, 52.52],
|
||||
"asia-east": [139.6917, 35.6895],
|
||||
"asia-south": [77.1025, 28.7041],
|
||||
"default": [0, 20]
|
||||
}
|
||||
|
||||
const nodeLocations = Array.isArray(nodes)
|
||||
? nodes.map((node: any, idx: number) => {
|
||||
const region = node.region || "default"
|
||||
const coords = regions[region] || regions.default
|
||||
// Add small random offset to prevent overlap
|
||||
const offset = [(Math.random() - 0.5) * 10, (Math.random() - 0.5) * 10]
|
||||
return {
|
||||
id: node.id || `node-${idx}`,
|
||||
name: node.name || `Node ${idx + 1}`,
|
||||
point: [coords[0] + offset[0], coords[1] + offset[1]] as [number, number],
|
||||
count: 1,
|
||||
status: (node.status === "running" ? "running" : node.status === "syncing" ? "syncing" : "stopped") as "running" | "stopped" | "syncing" | "error",
|
||||
chain: node.chain || node.network
|
||||
}
|
||||
})
|
||||
: []
|
||||
|
||||
// Common RPC methods with placeholder counts
|
||||
const methodBreakdown = [
|
||||
{ method: "eth_call", count: 0, percentage: 25 },
|
||||
{ method: "eth_getBalance", count: 0, percentage: 20 },
|
||||
{ method: "eth_getLogs", count: 0, percentage: 18 },
|
||||
{ method: "eth_getTransactionReceipt", count: 0, percentage: 15 },
|
||||
{ method: "eth_blockNumber", count: 0, percentage: 12 },
|
||||
{ method: "Others", count: 0, percentage: 10 },
|
||||
]
|
||||
|
||||
setData({
|
||||
totalRequests: 0, // Would come from usage tracking API
|
||||
computeUnits: 0,
|
||||
avgResponseTime: 0,
|
||||
successRate: 0,
|
||||
chainBreakdown,
|
||||
methodBreakdown,
|
||||
nodeLocations
|
||||
})
|
||||
setLastUpdated(new Date())
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch analytics:", err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchAnalytics()
|
||||
}, [])
|
||||
|
||||
const hasData = data && (data.totalRequests > 0 || data.nodeLocations.length > 0 || data.chainBreakdown.length > 0)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Analytics</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Monitor your API usage, performance metrics, and blockchain data.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{lastUpdated && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Updated {lastUpdated.toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchAnalytics}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCcw className={`h-4 w-4 mr-2 ${loading ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Requests</CardTitle>
|
||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
) : data?.totalRequests ? (
|
||||
<>
|
||||
<div className="text-2xl font-bold">
|
||||
{data.totalRequests.toLocaleString()}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<TrendingUp className="inline h-3 w-3 mr-1" />
|
||||
API calls this period
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold text-muted-foreground">-</div>
|
||||
<p className="text-xs text-muted-foreground">No data yet</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Compute Units</CardTitle>
|
||||
<Zap className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
) : data?.computeUnits ? (
|
||||
<>
|
||||
<div className="text-2xl font-bold">
|
||||
{(data.computeUnits / 1000000).toFixed(2)}M
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Units consumed
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold text-muted-foreground">-</div>
|
||||
<p className="text-xs text-muted-foreground">No usage data</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Response Time</CardTitle>
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
) : data?.avgResponseTime ? (
|
||||
<>
|
||||
<div className="text-2xl font-bold">{data.avgResponseTime}ms</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Average latency
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold text-muted-foreground">-</div>
|
||||
<p className="text-xs text-muted-foreground">No latency data</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Success Rate</CardTitle>
|
||||
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
) : data?.successRate ? (
|
||||
<>
|
||||
<div className="text-2xl font-bold">{data.successRate}%</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Request success rate
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold text-muted-foreground">-</div>
|
||||
<p className="text-xs text-muted-foreground">No success data</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Node Map */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Globe className="h-5 w-5" />
|
||||
Global Node Distribution
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Geographic distribution of your blockchain nodes
|
||||
</CardDescription>
|
||||
</div>
|
||||
{data?.nodeLocations && data.nodeLocations.length > 0 && (
|
||||
<Badge variant="secondary">
|
||||
{data.nodeLocations.length} node{data.nodeLocations.length !== 1 ? "s" : ""}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="h-[400px] flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : data?.nodeLocations && data.nodeLocations.length > 0 ? (
|
||||
<NodeMap
|
||||
data={data.nodeLocations}
|
||||
width={800}
|
||||
height={400}
|
||||
className="mx-auto"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-[300px] flex flex-col items-center justify-center text-muted-foreground">
|
||||
<Server className="h-12 w-12 mb-4 opacity-50" />
|
||||
<p className="font-medium">No nodes deployed yet</p>
|
||||
<p className="text-sm">Deploy nodes to see them on the map</p>
|
||||
<Button variant="outline" className="mt-4" asChild>
|
||||
<a href="/dashboard/infrastructure/nodes">Deploy a Node</a>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Charts Row */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Chain Distribution */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Chain Distribution</CardTitle>
|
||||
<CardDescription>API requests by blockchain network</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="h-[250px] flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : data?.chainBreakdown && data.chainBreakdown.length > 0 ? (
|
||||
<DonutChart
|
||||
data={data.chainBreakdown}
|
||||
width={200}
|
||||
height={200}
|
||||
showLegend={true}
|
||||
postfix="%"
|
||||
centerValue={data.chainBreakdown.length.toString()}
|
||||
centerLabel="chains"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-[200px] flex flex-col items-center justify-center text-muted-foreground">
|
||||
<AlertCircle className="h-8 w-8 mb-2 opacity-50" />
|
||||
<p>No chain data available</p>
|
||||
<p className="text-sm">Start making API calls to see distribution</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Top API Methods */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Top API Methods</CardTitle>
|
||||
<CardDescription>Most frequently called RPC methods</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="h-[250px] flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : data?.methodBreakdown && data.totalRequests > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{data.methodBreakdown.map((method, idx) => (
|
||||
<div key={method.method} className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="font-mono">{method.method}</span>
|
||||
<span className="text-muted-foreground">{method.percentage}%</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${method.percentage}%`,
|
||||
backgroundColor: `hsl(${220 - idx * 20}, 70%, 50%)`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[200px] flex flex-col items-center justify-center text-muted-foreground">
|
||||
<AlertCircle className="h-8 w-8 mb-2 opacity-50" />
|
||||
<p>No method data available</p>
|
||||
<p className="text-sm">API usage will appear here</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Info Banner */}
|
||||
<Card className="bg-muted/50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-muted-foreground mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium">Analytics Data</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Detailed usage analytics require connection to Hanzo Datastore.
|
||||
Configure your datastore connection in settings to enable full analytics tracking.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Plus,
|
||||
Copy,
|
||||
Trash2,
|
||||
Key,
|
||||
Check,
|
||||
X,
|
||||
Loader2,
|
||||
} from "lucide-react"
|
||||
import { useApiKeys, useCreateApiKey, useDeleteApiKey } from "@/lib/hooks"
|
||||
|
||||
function getProjectId(): string {
|
||||
if (typeof window === "undefined") return ""
|
||||
return localStorage.getItem("bootnode_project_id") || ""
|
||||
}
|
||||
|
||||
export default function ApiKeysPage() {
|
||||
const [projectId, setProjectId] = useState("")
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [newKeyName, setNewKeyName] = useState("")
|
||||
const [newKeyRateLimit, setNewKeyRateLimit] = useState("")
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null)
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
|
||||
const [projectInput, setProjectInput] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
setProjectId(getProjectId())
|
||||
}, [])
|
||||
|
||||
const keysQuery = useApiKeys(projectId)
|
||||
const createKey = useCreateApiKey()
|
||||
const deleteKey = useDeleteApiKey()
|
||||
|
||||
const keys = Array.isArray(keysQuery.data) ? keysQuery.data : []
|
||||
|
||||
function handleSetProject() {
|
||||
if (!projectInput.trim()) return
|
||||
localStorage.setItem("bootnode_project_id", projectInput.trim())
|
||||
setProjectId(projectInput.trim())
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!newKeyName.trim() || !projectId) return
|
||||
try {
|
||||
await createKey.mutateAsync({
|
||||
project_id: projectId,
|
||||
name: newKeyName.trim(),
|
||||
rate_limit: newKeyRateLimit ? parseInt(newKeyRateLimit) : undefined,
|
||||
})
|
||||
setNewKeyName("")
|
||||
setNewKeyRateLimit("")
|
||||
setShowCreate(false)
|
||||
} catch {
|
||||
// error is available via createKey.error
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(keyId: string) {
|
||||
try {
|
||||
await deleteKey.mutateAsync(keyId)
|
||||
setDeleteConfirm(null)
|
||||
} catch {
|
||||
// error is available via deleteKey.error
|
||||
}
|
||||
}
|
||||
|
||||
function handleCopy(text: string, id: string) {
|
||||
navigator.clipboard.writeText(text)
|
||||
setCopiedId(id)
|
||||
setTimeout(() => setCopiedId(null), 2000)
|
||||
}
|
||||
|
||||
if (!projectId) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">API Keys</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage your API keys and access credentials
|
||||
</p>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Set Project ID</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your project ID to view and manage API keys. If you used the dev seed, check the API logs for the project ID.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Project ID (e.g., proj_...)"
|
||||
value={projectInput}
|
||||
onChange={(e) => setProjectInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSetProject()}
|
||||
/>
|
||||
<Button onClick={handleSetProject}>Set Project</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">API Keys</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage your API keys and access credentials
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreate(!showCreate)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create API Key
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Create Form */}
|
||||
{showCreate && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Create New API Key</CardTitle>
|
||||
<CardDescription>Generate a new key for your project</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Key Name</label>
|
||||
<Input
|
||||
placeholder="e.g., Production, Development"
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Rate Limit (req/min, optional)</label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="e.g., 100"
|
||||
value={newKeyRateLimit}
|
||||
onChange={(e) => setNewKeyRateLimit(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{createKey.error && (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-destructive text-sm">
|
||||
{String(createKey.error)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleCreate} disabled={createKey.isPending || !newKeyName.trim()}>
|
||||
{createKey.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Create Key
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setShowCreate(false)}>Cancel</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Keys</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{keysQuery.isLoading ? "..." : keys.length}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Project ID</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<code className="text-sm text-muted-foreground break-all">{projectId}</code>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Badge variant={keysQuery.error ? "destructive" : "default"}>
|
||||
{keysQuery.isLoading ? "Loading" : keysQuery.error ? "Error" : "Connected"}
|
||||
</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* API Keys List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Your API Keys</CardTitle>
|
||||
<CardDescription>
|
||||
Use these keys to authenticate your API requests
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{keysQuery.isLoading ? (
|
||||
<div className="flex items-center justify-center p-12">
|
||||
<div className="text-muted-foreground">Loading API keys...</div>
|
||||
</div>
|
||||
) : keysQuery.error ? (
|
||||
<div className="rounded-md bg-destructive/10 p-4 text-destructive">
|
||||
{String(keysQuery.error)}
|
||||
</div>
|
||||
) : keys.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground p-12">
|
||||
No API keys yet. Create your first one above.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{keys.map((key: { id?: string; key_id?: string; name?: string; prefix?: string; key_prefix?: string; created_at?: string; is_active?: boolean; status?: string; rate_limit?: number }) => {
|
||||
const keyId = key.key_id || key.id || ""
|
||||
const prefix = key.key_prefix || key.prefix || keyId.slice(0, 12)
|
||||
const isActive = key.is_active !== false && key.status !== "revoked"
|
||||
return (
|
||||
<div
|
||||
key={keyId}
|
||||
className="flex items-center justify-between p-4 border rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<Key className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-medium">{key.name || "Unnamed Key"}</h4>
|
||||
<Badge variant={isActive ? "default" : "secondary"}>
|
||||
{isActive ? "active" : "revoked"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<code className="text-sm text-muted-foreground font-mono">
|
||||
{prefix}{"..."}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => handleCopy(prefix, keyId)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
title="Copy key prefix"
|
||||
>
|
||||
{copiedId === keyId ? (
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{key.rate_limit && (
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium">{key.rate_limit}/min</p>
|
||||
<p className="text-xs text-muted-foreground">rate limit</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-muted-foreground">Created</p>
|
||||
<p className="text-xs">
|
||||
{key.created_at
|
||||
? new Date(key.created_at).toLocaleDateString()
|
||||
: "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{deleteConfirm === keyId ? (
|
||||
<>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(keyId)}
|
||||
disabled={deleteKey.isPending}
|
||||
>
|
||||
{deleteKey.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
"Confirm"
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setDeleteConfirm(null)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive"
|
||||
onClick={() => setDeleteConfirm(keyId)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{deleteKey.error && (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-destructive text-sm mt-4">
|
||||
{String(deleteKey.error)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Permissions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Key Permissions</CardTitle>
|
||||
<CardDescription>
|
||||
Configure what each API key can access
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="p-4 border rounded-lg">
|
||||
<h4 className="font-medium">Read Access</h4>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Query blockchain data, account balances, transaction history
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 border rounded-lg">
|
||||
<h4 className="font-medium">Write Access</h4>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Send transactions, deploy contracts, execute operations
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 border rounded-lg">
|
||||
<h4 className="font-medium">Webhook Management</h4>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Create, update, and delete webhook subscriptions
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 border rounded-lg">
|
||||
<h4 className="font-medium">Admin Access</h4>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Manage API keys, billing, and project settings
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Forward request to the Bootnode API backend
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8100'}/v1/auth/api-keys/keys`, {
|
||||
headers: {
|
||||
'Authorization': request.headers.get('authorization') || '',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch API keys' },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
// Forward request to the Bootnode API backend
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8100'}/v1/auth/api-keys/keys`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': request.headers.get('authorization') || '',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create API key' },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const keyId = searchParams.get('id')
|
||||
|
||||
if (!keyId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Key ID is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Forward request to the Bootnode API backend
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8100'}/v1/auth/api-keys/keys/${keyId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': request.headers.get('authorization') || '',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete API key' },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client"
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Coins, Image, Wallet } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
|
||||
export default function AssetsPage() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Assets</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage tokens, NFTs, and digital assets
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Coins className="h-5 w-5" />
|
||||
Tokens
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
ERC-20 token management
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Query token balances, metadata, and transfers
|
||||
</p>
|
||||
<Button variant="outline" className="w-full" asChild>
|
||||
<Link href="/dashboard/assets/tokens">
|
||||
Manage Tokens
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Image className="h-5 w-5" />
|
||||
NFTs
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
ERC-721/1155 collections
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Browse NFT collections, ownership, and metadata
|
||||
</p>
|
||||
<Button variant="outline" className="w-full">
|
||||
Browse NFTs
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Wallet className="h-5 w-5" />
|
||||
Portfolios
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Track wallet holdings
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
View aggregated portfolio across chains
|
||||
</p>
|
||||
<Button variant="outline" className="w-full">
|
||||
View Portfolios
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Asset APIs</CardTitle>
|
||||
<CardDescription>
|
||||
Available endpoints for asset management
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ method: "GET", path: "/v1/tokens/{chain}/balances/{address}", description: "Get token balances" },
|
||||
{ method: "GET", path: "/v1/tokens/{chain}/metadata/{contract}", description: "Get token metadata" },
|
||||
{ method: "GET", path: "/v1/nfts/{chain}/owned/{address}", description: "Get owned NFTs" },
|
||||
{ method: "GET", path: "/v1/nfts/{chain}/metadata/{contract}/{tokenId}", description: "Get NFT metadata" },
|
||||
{ method: "POST", path: "/v1/nfts/{chain}/refresh/{contract}/{tokenId}", description: "Refresh NFT metadata" },
|
||||
].map((endpoint) => (
|
||||
<div key={endpoint.path} className="flex items-center gap-3 p-3 border rounded-lg">
|
||||
<Badge variant="default" className="font-mono text-xs w-12 justify-center">
|
||||
{endpoint.method}
|
||||
</Badge>
|
||||
<code className="text-sm font-mono flex-1">{endpoint.path}</code>
|
||||
<span className="text-sm text-muted-foreground">{endpoint.description}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Coins, Search, Loader2 } from "lucide-react"
|
||||
|
||||
interface TokenMetadata {
|
||||
contract_address: string
|
||||
name: string
|
||||
symbol: string
|
||||
decimals: number
|
||||
total_supply?: string
|
||||
logo_url?: string
|
||||
}
|
||||
|
||||
export default function TokensPage() {
|
||||
const [chain, setChain] = useState("ethereum")
|
||||
const [contract, setContract] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [token, setToken] = useState<TokenMetadata | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
function getAuthHeaders(): Record<string, string> {
|
||||
const token = localStorage.getItem("bootnode_token")
|
||||
const apiKey = localStorage.getItem("bootnode_api_key")
|
||||
if (token) return { "Authorization": `Bearer ${token}` }
|
||||
if (apiKey) return { "X-API-Key": apiKey }
|
||||
return {}
|
||||
}
|
||||
|
||||
async function handleSearch() {
|
||||
if (!contract.trim()) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setToken(null)
|
||||
|
||||
try {
|
||||
const res = await fetch(`http://localhost:8100/v1/tokens/${chain}/metadata/${contract}`, {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch token: ${res.status}`)
|
||||
}
|
||||
const data = await res.json()
|
||||
setToken(data)
|
||||
} catch (e) {
|
||||
setError(String(e))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Tokens</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Query ERC-20 token metadata and balances
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Search className="h-5 w-5" />
|
||||
Token Lookup
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Search for token metadata by contract address
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Chain</label>
|
||||
<Input
|
||||
value={chain}
|
||||
onChange={(e) => setChain(e.target.value)}
|
||||
placeholder="ethereum, polygon, base..."
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="text-sm font-medium">Contract Address</label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<Input
|
||||
value={contract}
|
||||
onChange={(e) => setContract(e.target.value)}
|
||||
placeholder="0x..."
|
||||
className="font-mono"
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
/>
|
||||
<Button onClick={handleSearch} disabled={loading || !contract.trim()}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 p-4 text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{token && (
|
||||
<div className="p-4 border rounded-lg space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Coins className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-lg">{token.name}</h3>
|
||||
<p className="text-muted-foreground">{token.symbol}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Contract</p>
|
||||
<code className="text-sm font-mono">{token.contract_address}</code>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Decimals</p>
|
||||
<p className="font-medium">{token.decimals}</p>
|
||||
</div>
|
||||
{token.total_supply && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Total Supply</p>
|
||||
<p className="font-medium">{token.total_supply}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Popular Tokens</CardTitle>
|
||||
<CardDescription>Quick access to commonly queried tokens</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{[
|
||||
{ symbol: "USDC", contract: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", chain: "ethereum" },
|
||||
{ symbol: "USDT", contract: "0xdAC17F958D2ee523a2206206994597C13D831ec7", chain: "ethereum" },
|
||||
{ symbol: "WETH", contract: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", chain: "ethereum" },
|
||||
{ symbol: "DAI", contract: "0x6B175474E89094C44Da98b954EescdeCB5Bf6BB", chain: "ethereum" },
|
||||
].map((t) => (
|
||||
<Button
|
||||
key={t.symbol}
|
||||
variant="outline"
|
||||
className="justify-start"
|
||||
onClick={() => { setChain(t.chain); setContract(t.contract); }}
|
||||
>
|
||||
<Coins className="mr-2 h-4 w-4" />
|
||||
{t.symbol}
|
||||
<Badge variant="secondary" className="ml-auto">{t.chain}</Badge>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Plus,
|
||||
Fuel,
|
||||
Trash2,
|
||||
Loader2,
|
||||
X,
|
||||
DollarSign,
|
||||
Zap,
|
||||
Settings,
|
||||
} from "lucide-react"
|
||||
import { useGasPolicies, useCreateGasPolicy, useDeleteGasPolicy, useGasPrices } from "@/lib/hooks"
|
||||
|
||||
export default function GasPoliciesPage() {
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [name, setName] = useState("")
|
||||
const [chain, setChain] = useState("ethereum")
|
||||
const [gpNetwork, setGpNetwork] = useState("")
|
||||
const [maxGasPerOp, setMaxGasPerOp] = useState("")
|
||||
const [maxSpendPerDay, setMaxSpendPerDay] = useState("")
|
||||
const [allowedContracts, setAllowedContracts] = useState("")
|
||||
const [allowedMethods, setAllowedMethods] = useState("")
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
|
||||
const [priceChain, setPriceChain] = useState("ethereum")
|
||||
|
||||
const policiesQuery = useGasPolicies()
|
||||
const createPolicy = useCreateGasPolicy()
|
||||
const deletePolicy = useDeleteGasPolicy()
|
||||
const gasPricesQuery = useGasPrices(priceChain)
|
||||
|
||||
const policies = Array.isArray(policiesQuery.data) ? policiesQuery.data : []
|
||||
const gasPrices = gasPricesQuery.data as { slow?: string; standard?: string; fast?: string; base_fee?: string; unit?: string } | null
|
||||
|
||||
async function handleCreate() {
|
||||
if (!name.trim() || !chain.trim()) return
|
||||
try {
|
||||
await createPolicy.mutateAsync({
|
||||
name: name.trim(),
|
||||
chain: chain.trim(),
|
||||
network: gpNetwork.trim() || undefined,
|
||||
max_gas_per_op: maxGasPerOp ? parseInt(maxGasPerOp) : undefined,
|
||||
max_spend_per_day_usd: maxSpendPerDay ? parseFloat(maxSpendPerDay) : undefined,
|
||||
allowed_contracts: allowedContracts.trim()
|
||||
? allowedContracts.split(",").map((s) => s.trim()).filter(Boolean)
|
||||
: undefined,
|
||||
allowed_methods: allowedMethods.trim()
|
||||
? allowedMethods.split(",").map((s) => s.trim()).filter(Boolean)
|
||||
: undefined,
|
||||
})
|
||||
setName("")
|
||||
setChain("ethereum")
|
||||
setGpNetwork("")
|
||||
setMaxGasPerOp("")
|
||||
setMaxSpendPerDay("")
|
||||
setAllowedContracts("")
|
||||
setAllowedMethods("")
|
||||
setShowCreate(false)
|
||||
} catch {
|
||||
// error available via createPolicy.error
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
try {
|
||||
await deletePolicy.mutateAsync(id)
|
||||
setDeleteConfirm(null)
|
||||
} catch {
|
||||
// error available via deletePolicy.error
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Gas Policies</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Configure gas sponsorship and paymaster settings
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreate(!showCreate)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Policy
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Create Form */}
|
||||
{showCreate && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Create Gas Policy</CardTitle>
|
||||
<CardDescription>Define gas sponsorship rules</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Policy Name</label>
|
||||
<Input
|
||||
placeholder="e.g., Free Tier, Premium"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Chain</label>
|
||||
<Input
|
||||
placeholder="ethereum, base, polygon..."
|
||||
value={chain}
|
||||
onChange={(e) => setChain(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Network (optional)</label>
|
||||
<Input
|
||||
placeholder="mainnet, testnet..."
|
||||
value={gpNetwork}
|
||||
onChange={(e) => setGpNetwork(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Max Gas Per Operation</label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="e.g., 500000"
|
||||
value={maxGasPerOp}
|
||||
onChange={(e) => setMaxGasPerOp(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Max Spend Per Day (USD)</label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="e.g., 50.00"
|
||||
value={maxSpendPerDay}
|
||||
onChange={(e) => setMaxSpendPerDay(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Allowed Contracts (comma-separated)</label>
|
||||
<Input
|
||||
placeholder="0x..., 0x..."
|
||||
value={allowedContracts}
|
||||
onChange={(e) => setAllowedContracts(e.target.value)}
|
||||
className="mt-1 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Allowed Methods (comma-separated)</label>
|
||||
<Input
|
||||
placeholder="transfer, approve, mint..."
|
||||
value={allowedMethods}
|
||||
onChange={(e) => setAllowedMethods(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
{createPolicy.error && (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-destructive text-sm">
|
||||
{String(createPolicy.error)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleCreate} disabled={createPolicy.isPending || !name.trim()}>
|
||||
{createPolicy.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Create Policy
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setShowCreate(false)}>Cancel</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Policies</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{policiesQuery.isLoading ? "..." : policies.length}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Badge variant={policiesQuery.error ? "destructive" : "default"}>
|
||||
{policiesQuery.isLoading ? "Loading" : policiesQuery.error ? "Error" : "Connected"}
|
||||
</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Spending Stats</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">Connect analytics to track</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Gas Prices */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Current Gas Prices</CardTitle>
|
||||
<CardDescription>Live gas prices for selected chain</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex gap-2 items-end">
|
||||
<div className="flex-1">
|
||||
<label className="text-sm font-medium">Chain</label>
|
||||
<Input
|
||||
value={priceChain}
|
||||
onChange={(e) => setPriceChain(e.target.value)}
|
||||
placeholder="ethereum, base, polygon..."
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{gasPricesQuery.isLoading ? (
|
||||
<div className="text-muted-foreground text-sm">Loading gas prices...</div>
|
||||
) : gasPricesQuery.error ? (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-destructive text-sm">
|
||||
{String(gasPricesQuery.error)}
|
||||
</div>
|
||||
) : gasPrices ? (
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{gasPrices.slow !== undefined && (
|
||||
<div className="p-3 border rounded-lg text-center">
|
||||
<p className="text-xs text-muted-foreground">Slow</p>
|
||||
<p className="text-lg font-bold">{gasPrices.slow}</p>
|
||||
<p className="text-xs text-muted-foreground">{gasPrices.unit || "gwei"}</p>
|
||||
</div>
|
||||
)}
|
||||
{gasPrices.standard !== undefined && (
|
||||
<div className="p-3 border rounded-lg text-center">
|
||||
<p className="text-xs text-muted-foreground">Standard</p>
|
||||
<p className="text-lg font-bold">{gasPrices.standard}</p>
|
||||
<p className="text-xs text-muted-foreground">{gasPrices.unit || "gwei"}</p>
|
||||
</div>
|
||||
)}
|
||||
{gasPrices.fast !== undefined && (
|
||||
<div className="p-3 border rounded-lg text-center">
|
||||
<p className="text-xs text-muted-foreground">Fast</p>
|
||||
<p className="text-lg font-bold">{gasPrices.fast}</p>
|
||||
<p className="text-xs text-muted-foreground">{gasPrices.unit || "gwei"}</p>
|
||||
</div>
|
||||
)}
|
||||
{gasPrices.base_fee !== undefined && (
|
||||
<div className="p-3 border rounded-lg text-center">
|
||||
<p className="text-xs text-muted-foreground">Base Fee</p>
|
||||
<p className="text-lg font-bold">{gasPrices.base_fee}</p>
|
||||
<p className="text-xs text-muted-foreground">{gasPrices.unit || "gwei"}</p>
|
||||
</div>
|
||||
)}
|
||||
{!gasPrices.slow && !gasPrices.standard && !gasPrices.fast && !gasPrices.base_fee && (
|
||||
<div className="col-span-3">
|
||||
<pre className="text-sm text-muted-foreground bg-muted/50 p-3 rounded overflow-auto">
|
||||
{JSON.stringify(gasPrices, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground text-sm">No gas price data available.</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Policies List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Your Policies</CardTitle>
|
||||
<CardDescription>
|
||||
Gas sponsorship rules for your applications
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{policiesQuery.isLoading ? (
|
||||
<div className="flex items-center justify-center p-12">
|
||||
<div className="text-muted-foreground">Loading policies...</div>
|
||||
</div>
|
||||
) : policiesQuery.error ? (
|
||||
<div className="rounded-md bg-destructive/10 p-4 text-destructive">
|
||||
{String(policiesQuery.error)}
|
||||
</div>
|
||||
) : policies.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground p-12">
|
||||
No gas policies yet. Create your first one above.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{policies.map((policy: { id?: string; policy_id?: string; name?: string; chain?: string; network?: string; max_gas_per_op?: number; max_spend_per_day_usd?: number; allowed_contracts?: string[]; allowed_methods?: string[]; is_active?: boolean; created_at?: string }) => {
|
||||
const pId = policy.policy_id || policy.id || ""
|
||||
const isActive = policy.is_active !== false
|
||||
return (
|
||||
<div
|
||||
key={pId}
|
||||
className="flex items-center justify-between p-4 border rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<Fuel className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-medium">{policy.name || "Unnamed"}</h4>
|
||||
<Badge variant={isActive ? "default" : "secondary"}>
|
||||
{isActive ? "active" : "inactive"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{policy.chain || "any"}
|
||||
</Badge>
|
||||
{policy.network && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{policy.network}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
{policy.max_spend_per_day_usd !== undefined && (
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium">${policy.max_spend_per_day_usd}/day</p>
|
||||
<p className="text-xs text-muted-foreground">spend limit</p>
|
||||
</div>
|
||||
)}
|
||||
{policy.max_gas_per_op !== undefined && (
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium">{policy.max_gas_per_op.toLocaleString()}</p>
|
||||
<p className="text-xs text-muted-foreground">gas/op</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
{deleteConfirm === pId ? (
|
||||
<>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(pId)}
|
||||
disabled={deletePolicy.isPending}
|
||||
>
|
||||
{deletePolicy.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
"Delete"
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setDeleteConfirm(null)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive"
|
||||
onClick={() => setDeleteConfirm(pId)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{deletePolicy.error && (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-destructive text-sm mt-4">
|
||||
{String(deletePolicy.error)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Policy Types */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Policy Types</CardTitle>
|
||||
<CardDescription>
|
||||
Choose how to sponsor gas for your users
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[
|
||||
{ icon: DollarSign, name: "Fully Sponsored", description: "Pay 100% of gas fees for your users" },
|
||||
{ icon: Zap, name: "Subsidized", description: "Pay a percentage of gas, users cover the rest" },
|
||||
{ icon: Settings, name: "Conditional", description: "Sponsor gas based on rules and conditions" },
|
||||
].map((type) => (
|
||||
<div key={type.name} className="p-4 border rounded-lg">
|
||||
<type.icon className="h-8 w-8 text-primary mb-3" />
|
||||
<h4 className="font-medium">{type.name}</h4>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{type.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Box, Plus, Loader2, X, Settings, Download, Scale } from "lucide-react"
|
||||
|
||||
interface Cluster {
|
||||
id: string
|
||||
name: string
|
||||
provider: string
|
||||
region: string
|
||||
status: string
|
||||
kubernetes_version: string
|
||||
node_count: number
|
||||
node_size: string
|
||||
endpoint: string | null
|
||||
created_at: string
|
||||
node_pools: { name: string; count: number; size: string }[]
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
export default function ClustersPage() {
|
||||
const [clusters, setClusters] = useState<Cluster[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
// Form state
|
||||
const [name, setName] = useState("")
|
||||
const [provider, setProvider] = useState("digitalocean")
|
||||
const [region, setRegion] = useState("nyc1")
|
||||
const [nodeCount, setNodeCount] = useState(3)
|
||||
const [nodeSize, setNodeSize] = useState("s-2vcpu-4gb")
|
||||
|
||||
function getAuthHeaders(): Record<string, string> {
|
||||
const token = localStorage.getItem("bootnode_token")
|
||||
const apiKey = localStorage.getItem("bootnode_api_key")
|
||||
if (token) return { "Authorization": `Bearer ${token}` }
|
||||
if (apiKey) return { "X-API-Key": apiKey }
|
||||
return {}
|
||||
}
|
||||
|
||||
async function fetchClusters() {
|
||||
try {
|
||||
const res = await fetch("http://localhost:8100/v1/infra/clusters", {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setClusters(Array.isArray(data) ? data : [])
|
||||
} else {
|
||||
setError(`Failed to fetch clusters: ${res.status}`)
|
||||
}
|
||||
} catch (e) {
|
||||
setError(String(e))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchClusters()
|
||||
}, [])
|
||||
|
||||
async function handleCreate() {
|
||||
if (!name.trim()) return
|
||||
setCreating(true)
|
||||
try {
|
||||
const res = await fetch("http://localhost:8100/v1/infra/clusters", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...getAuthHeaders()
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: name.trim(),
|
||||
provider,
|
||||
region,
|
||||
node_count: nodeCount,
|
||||
node_size: nodeSize,
|
||||
})
|
||||
})
|
||||
if (res.ok) {
|
||||
const newCluster = await res.json()
|
||||
setClusters(prev => [...prev, newCluster])
|
||||
setShowCreate(false)
|
||||
setName("")
|
||||
}
|
||||
} catch (e) {
|
||||
setError(String(e))
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadKubeconfig(clusterId: string) {
|
||||
try {
|
||||
const res = await fetch(`http://localhost:8100/v1/infra/clusters/${clusterId}/kubeconfig`, {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const blob = new Blob([data.kubeconfig], { type: "text/yaml" })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = `${clusterId}-kubeconfig.yaml`
|
||||
a.click()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Kubernetes Clusters</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage K8s clusters for blockchain node deployment
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreate(!showCreate)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Cluster
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Create New Cluster</CardTitle>
|
||||
<CardDescription>Deploy a new Kubernetes cluster</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Cluster Name</label>
|
||||
<Input
|
||||
placeholder="e.g., Production Cluster"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Provider</label>
|
||||
<Input
|
||||
value={provider}
|
||||
onChange={(e) => setProvider(e.target.value)}
|
||||
placeholder="digitalocean, aws, gcp"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Region</label>
|
||||
<Input
|
||||
value={region}
|
||||
onChange={(e) => setRegion(e.target.value)}
|
||||
placeholder="nyc1, sfo3, etc."
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Node Count</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={nodeCount}
|
||||
onChange={(e) => setNodeCount(parseInt(e.target.value) || 3)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Node Size</label>
|
||||
<Input
|
||||
value={nodeSize}
|
||||
onChange={(e) => setNodeSize(e.target.value)}
|
||||
placeholder="s-2vcpu-4gb"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleCreate} disabled={creating || !name.trim()}>
|
||||
{creating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Create Cluster
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setShowCreate(false)}>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Clusters</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{loading ? "..." : clusters.length}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Running</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{loading ? "..." : clusters.filter(c => c.status === "running").length}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Nodes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{loading ? "..." : clusters.reduce((sum, c) => sum + c.node_count, 0)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Clusters List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Your Clusters</CardTitle>
|
||||
<CardDescription>Kubernetes clusters for blockchain infrastructure</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center p-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="rounded-md bg-destructive/10 p-4 text-destructive">{error}</div>
|
||||
) : clusters.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground p-12">
|
||||
No clusters yet. Click "Create Cluster" to get started.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{clusters.map((cluster) => (
|
||||
<div key={cluster.id} className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<Box className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-medium">{cluster.name}</h4>
|
||||
<Badge variant={cluster.status === "running" ? "default" : "secondary"}>
|
||||
{cluster.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant="outline" className="text-xs">{cluster.provider}</Badge>
|
||||
<Badge variant="outline" className="text-xs">{cluster.region}</Badge>
|
||||
<Badge variant="outline" className="text-xs">k8s {cluster.kubernetes_version}</Badge>
|
||||
<Badge variant="outline" className="text-xs">{cluster.node_count} nodes</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon" onClick={() => downloadKubeconfig(cluster.id)} title="Download kubeconfig">
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" title="Scale cluster">
|
||||
<Scale className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" title="Settings">
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Server, Plus, ExternalLink, Loader2, X } from "lucide-react"
|
||||
|
||||
interface Node {
|
||||
id: string
|
||||
name: string
|
||||
chain: string
|
||||
network: string
|
||||
provider: string
|
||||
status: string
|
||||
endpoint: string | null
|
||||
created_at: string
|
||||
metrics?: {
|
||||
block_height?: number
|
||||
peer_count?: number
|
||||
sync_progress?: number
|
||||
cpu_usage?: number
|
||||
memory_usage?: number
|
||||
}
|
||||
}
|
||||
|
||||
export default function NodesPage() {
|
||||
const [nodes, setNodes] = useState<Node[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [createError, setCreateError] = useState<string | null>(null)
|
||||
|
||||
// Form state
|
||||
const [name, setName] = useState("")
|
||||
const [chain, setChain] = useState("ethereum")
|
||||
const [network, setNetwork] = useState("mainnet")
|
||||
const [provider, setProvider] = useState("docker")
|
||||
|
||||
function getAuthHeaders(): Record<string, string> {
|
||||
const token = localStorage.getItem("bootnode_token")
|
||||
const apiKey = localStorage.getItem("bootnode_api_key")
|
||||
if (token) return { "Authorization": `Bearer ${token}` }
|
||||
if (apiKey) return { "X-API-Key": apiKey }
|
||||
return {}
|
||||
}
|
||||
|
||||
async function fetchNodes() {
|
||||
try {
|
||||
const res = await fetch("http://localhost:8100/v1/nodes/", {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setNodes(Array.isArray(data) ? data : [])
|
||||
} else {
|
||||
setError(`Failed to fetch nodes: ${res.status}`)
|
||||
}
|
||||
} catch (e) {
|
||||
setError(String(e))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchNodes()
|
||||
}, [])
|
||||
|
||||
async function handleCreate() {
|
||||
if (!name.trim() || !chain.trim()) return
|
||||
setCreating(true)
|
||||
setCreateError(null)
|
||||
|
||||
try {
|
||||
const res = await fetch("http://localhost:8100/v1/nodes/", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...getAuthHeaders()
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: name.trim(),
|
||||
chain: chain.trim(),
|
||||
network: network.trim(),
|
||||
provider: provider.trim(),
|
||||
})
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: res.statusText }))
|
||||
throw new Error(err.detail || `Failed: ${res.status}`)
|
||||
}
|
||||
|
||||
const newNode = await res.json()
|
||||
setNodes(prev => [...prev, newNode])
|
||||
setName("")
|
||||
setChain("ethereum")
|
||||
setNetwork("mainnet")
|
||||
setProvider("docker")
|
||||
setShowCreate(false)
|
||||
} catch (e) {
|
||||
setCreateError(String(e))
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Nodes</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Deploy and manage blockchain nodes
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreate(!showCreate)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Deploy Node
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Create Form */}
|
||||
{showCreate && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Deploy New Node</CardTitle>
|
||||
<CardDescription>Configure and deploy a blockchain node</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Node Name</label>
|
||||
<Input
|
||||
placeholder="e.g., Ethereum Mainnet Node"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Chain</label>
|
||||
<Input
|
||||
placeholder="ethereum, bitcoin, solana..."
|
||||
value={chain}
|
||||
onChange={(e) => setChain(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Network</label>
|
||||
<Input
|
||||
placeholder="mainnet, testnet, sepolia..."
|
||||
value={network}
|
||||
onChange={(e) => setNetwork(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Provider</label>
|
||||
<Input
|
||||
placeholder="docker, aws, gcp..."
|
||||
value={provider}
|
||||
onChange={(e) => setProvider(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{createError && (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-destructive text-sm">
|
||||
{createError}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleCreate} disabled={creating || !name.trim()}>
|
||||
{creating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Deploy Node
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setShowCreate(false)}>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Nodes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{loading ? "..." : nodes.length}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Running</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{loading ? "..." : nodes.filter(n => n.status === "running").length}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Badge variant={error ? "destructive" : "default"}>
|
||||
{loading ? "Loading" : error ? "Error" : "Connected"}
|
||||
</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Nodes List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Your Nodes</CardTitle>
|
||||
<CardDescription>
|
||||
Blockchain nodes deployed through Bootnode
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center p-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="rounded-md bg-destructive/10 p-4 text-destructive">{error}</div>
|
||||
) : nodes.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground p-12">
|
||||
No nodes deployed yet. Click "Deploy Node" to get started.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{nodes.map((node) => (
|
||||
<div key={node.id} className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<Server className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-medium">{node.name}</h4>
|
||||
<Badge variant={node.status === "running" ? "default" : node.status === "starting" ? "secondary" : "outline"}>
|
||||
{node.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant="outline" className="text-xs">{node.chain}</Badge>
|
||||
<Badge variant="outline" className="text-xs">{node.network}</Badge>
|
||||
<Badge variant="outline" className="text-xs">{node.provider}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
{node.metrics && node.metrics.block_height && (
|
||||
<>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium">{node.metrics.block_height.toLocaleString()}</p>
|
||||
<p className="text-xs text-muted-foreground">block height</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium">{node.metrics.peer_count}</p>
|
||||
<p className="text-xs text-muted-foreground">peers</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{node.endpoint && (
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<a href={node.endpoint} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Supported Chains */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Supported Chains</CardTitle>
|
||||
<CardDescription>Available blockchain nodes for deployment</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-3 md:grid-cols-3 lg:grid-cols-5">
|
||||
{["ethereum", "bitcoin", "solana", "polygon", "arbitrum", "optimism", "base", "avalanche", "bsc", "lux"].map((c) => (
|
||||
<Button
|
||||
key={c}
|
||||
variant="outline"
|
||||
className="justify-start capitalize"
|
||||
onClick={() => { setChain(c); setShowCreate(true); }}
|
||||
>
|
||||
<Server className="mr-2 h-4 w-4" />
|
||||
{c}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Server, Box, HardDrive, Copy, Loader2 } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
|
||||
interface InfraStats {
|
||||
clusters: { total: number; running: number; total_nodes: number }
|
||||
volumes: { total: number; in_use: number; total_storage_gb: number; used_storage_gb: number }
|
||||
snapshots: { total: number }
|
||||
regions: string[]
|
||||
}
|
||||
|
||||
export default function InfrastructurePage() {
|
||||
const [stats, setStats] = useState<InfraStats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
function getAuthHeaders(): Record<string, string> {
|
||||
const token = localStorage.getItem("bootnode_token")
|
||||
const apiKey = localStorage.getItem("bootnode_api_key")
|
||||
if (token) return { "Authorization": `Bearer ${token}` }
|
||||
if (apiKey) return { "X-API-Key": apiKey }
|
||||
return {}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchStats() {
|
||||
try {
|
||||
const res = await fetch("http://localhost:8100/v1/infra/stats", {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
if (res.ok) {
|
||||
setStats(await res.json())
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchStats()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Infrastructure</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage K8s clusters, nodes, volumes, and storage
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">K8s Clusters</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{loading ? <Loader2 className="h-6 w-6 animate-spin" /> : stats?.clusters.total || 0}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{stats?.clusters.running || 0} running
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Nodes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{loading ? <Loader2 className="h-6 w-6 animate-spin" /> : stats?.clusters.total_nodes || 0}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">across clusters</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Volumes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{loading ? <Loader2 className="h-6 w-6 animate-spin" /> : stats?.volumes.total || 0}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{stats?.volumes.in_use || 0} in use
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Storage</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{loading ? <Loader2 className="h-6 w-6 animate-spin" /> : `${((stats?.volumes.total_storage_gb || 0) / 1000).toFixed(1)} TB`}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{stats?.snapshots.total || 0} snapshots
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Infrastructure Categories */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Server className="h-5 w-5" />
|
||||
Blockchain Nodes
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Deploy and manage dedicated blockchain nodes
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Full nodes for Ethereum, Bitcoin, Solana, and more chains.
|
||||
Direct RPC access with dedicated resources.
|
||||
</p>
|
||||
<Button variant="outline" className="w-full" asChild>
|
||||
<Link href="/dashboard/infrastructure/nodes">
|
||||
Manage Nodes
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Box className="h-5 w-5" />
|
||||
Kubernetes Clusters
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Manage K8s clusters for node deployment
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create and scale Kubernetes clusters across regions.
|
||||
Automatic node pool management and upgrades.
|
||||
</p>
|
||||
<Button variant="outline" className="w-full" asChild>
|
||||
<Link href="/dashboard/infrastructure/clusters">
|
||||
Manage Clusters
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<HardDrive className="h-5 w-5" />
|
||||
Persistent Volumes
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Block storage for blockchain data
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
High-performance NVMe storage for chain data.
|
||||
Automatic snapshots and cross-region replication.
|
||||
</p>
|
||||
<Button variant="outline" className="w-full" asChild>
|
||||
<Link href="/dashboard/infrastructure/volumes">
|
||||
Manage Volumes
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Copy className="h-5 w-5" />
|
||||
Storage Cloning
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Rapidly clone volumes for fast deployment
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Copy-on-write cloning for instant node deployment.
|
||||
Clone from volumes or snapshots across regions.
|
||||
</p>
|
||||
<Button variant="outline" className="w-full" asChild>
|
||||
<Link href="/dashboard/infrastructure/storage">
|
||||
Clone Storage
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* API Endpoints */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Infrastructure APIs</CardTitle>
|
||||
<CardDescription>Available endpoints for infrastructure management</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ method: "GET", path: "/v1/infra/clusters", description: "List K8s clusters" },
|
||||
{ method: "POST", path: "/v1/infra/clusters", description: "Create cluster" },
|
||||
{ method: "GET", path: "/v1/infra/clusters/{id}/kubeconfig", description: "Get kubeconfig" },
|
||||
{ method: "GET", path: "/v1/infra/volumes", description: "List volumes" },
|
||||
{ method: "POST", path: "/v1/infra/volumes", description: "Create volume" },
|
||||
{ method: "POST", path: "/v1/infra/snapshots", description: "Create snapshot" },
|
||||
{ method: "POST", path: "/v1/infra/clone", description: "Clone volume/snapshot" },
|
||||
{ method: "GET", path: "/v1/infra/stats", description: "Infrastructure stats" },
|
||||
].map((endpoint) => (
|
||||
<div key={endpoint.path + endpoint.method} className="flex items-center gap-3 p-3 border rounded-lg">
|
||||
<Badge variant="default" className="font-mono text-xs w-14 justify-center">
|
||||
{endpoint.method}
|
||||
</Badge>
|
||||
<code className="text-sm font-mono flex-1">{endpoint.path}</code>
|
||||
<span className="text-sm text-muted-foreground">{endpoint.description}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Copy, Loader2, X, HardDrive, Camera, CheckCircle, Clock, AlertCircle } from "lucide-react"
|
||||
|
||||
interface Volume {
|
||||
id: string
|
||||
name: string
|
||||
size_gb: number
|
||||
region: string
|
||||
status: string
|
||||
snapshots: { id: string; name: string; size_gb: number; created_at: string }[]
|
||||
}
|
||||
|
||||
interface Snapshot {
|
||||
id: string
|
||||
name: string
|
||||
volume_id: string
|
||||
size_gb: number
|
||||
status: string
|
||||
created_at: string
|
||||
region: string
|
||||
}
|
||||
|
||||
interface CloneJob {
|
||||
id: string
|
||||
name: string
|
||||
source_id: string
|
||||
source_type: string
|
||||
size_gb: number
|
||||
status: string
|
||||
progress: number
|
||||
estimated_completion: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export default function StoragePage() {
|
||||
const [volumes, setVolumes] = useState<Volume[]>([])
|
||||
const [snapshots, setSnapshots] = useState<Snapshot[]>([])
|
||||
const [clones, setClones] = useState<CloneJob[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showClone, setShowClone] = useState(false)
|
||||
const [cloning, setCloning] = useState(false)
|
||||
|
||||
// Clone form state
|
||||
const [cloneName, setCloneName] = useState("")
|
||||
const [sourceId, setSourceId] = useState("")
|
||||
const [targetRegion, setTargetRegion] = useState("")
|
||||
|
||||
function getAuthHeaders(): Record<string, string> {
|
||||
const token = localStorage.getItem("bootnode_token")
|
||||
const apiKey = localStorage.getItem("bootnode_api_key")
|
||||
if (token) return { "Authorization": `Bearer ${token}` }
|
||||
if (apiKey) return { "X-API-Key": apiKey }
|
||||
return {}
|
||||
}
|
||||
|
||||
async function fetchData() {
|
||||
try {
|
||||
const headers = getAuthHeaders()
|
||||
const [volRes, snapRes, cloneRes] = await Promise.all([
|
||||
fetch("http://localhost:8100/v1/infra/volumes", { headers }),
|
||||
fetch("http://localhost:8100/v1/infra/snapshots", { headers }),
|
||||
fetch("http://localhost:8100/v1/infra/clone", { headers }),
|
||||
])
|
||||
|
||||
if (volRes.ok) setVolumes(await volRes.json())
|
||||
if (snapRes.ok) setSnapshots(await snapRes.json())
|
||||
if (cloneRes.ok) setClones(await cloneRes.json())
|
||||
} catch (e) {
|
||||
setError(String(e))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
async function handleClone() {
|
||||
if (!cloneName.trim() || !sourceId) return
|
||||
setCloning(true)
|
||||
try {
|
||||
const res = await fetch("http://localhost:8100/v1/infra/clone", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...getAuthHeaders()
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: cloneName.trim(),
|
||||
source_id: sourceId,
|
||||
region: targetRegion || undefined,
|
||||
})
|
||||
})
|
||||
if (res.ok) {
|
||||
const newClone = await res.json()
|
||||
setClones(prev => [...prev, newClone])
|
||||
setShowClone(false)
|
||||
setCloneName("")
|
||||
setSourceId("")
|
||||
setTargetRegion("")
|
||||
}
|
||||
} catch (e) {
|
||||
setError(String(e))
|
||||
} finally {
|
||||
setCloning(false)
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusIcon(status: string) {
|
||||
switch (status) {
|
||||
case "completed": return <CheckCircle className="h-4 w-4 text-green-500" />
|
||||
case "cloning": return <Loader2 className="h-4 w-4 animate-spin text-blue-500" />
|
||||
case "error": return <AlertCircle className="h-4 w-4 text-red-500" />
|
||||
default: return <Clock className="h-4 w-4 text-muted-foreground" />
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Storage Cloning</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Rapidly clone volumes and snapshots for fast node deployment
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowClone(!showClone)}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Clone Storage
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showClone && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Clone Volume or Snapshot</CardTitle>
|
||||
<CardDescription>
|
||||
Create a copy-on-write clone for instant provisioning
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Clone Name</label>
|
||||
<Input
|
||||
placeholder="e.g., ETH Mainnet Clone"
|
||||
value={cloneName}
|
||||
onChange={(e) => setCloneName(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Source (Volume or Snapshot)</label>
|
||||
<select
|
||||
className="mt-1 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
value={sourceId}
|
||||
onChange={(e) => setSourceId(e.target.value)}
|
||||
>
|
||||
<option value="">Select source...</option>
|
||||
<optgroup label="Volumes">
|
||||
{volumes.map(v => (
|
||||
<option key={v.id} value={v.id}>{v.name} ({v.size_gb} GB)</option>
|
||||
))}
|
||||
</optgroup>
|
||||
<optgroup label="Snapshots">
|
||||
{snapshots.map(s => (
|
||||
<option key={s.id} value={s.id}>{s.name} ({s.size_gb} GB)</option>
|
||||
))}
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Target Region (optional)</label>
|
||||
<Input
|
||||
placeholder="Same as source"
|
||||
value={targetRegion}
|
||||
onChange={(e) => setTargetRegion(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleClone} disabled={cloning || !cloneName.trim() || !sourceId}>
|
||||
{cloning && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Start Clone
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setShowClone(false)}>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Clone Jobs */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Clone Operations</CardTitle>
|
||||
<CardDescription>Active and recent clone jobs</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center p-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : clones.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground p-12">
|
||||
No clone operations. Click "Clone Storage" to create one.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{clones.map((clone) => (
|
||||
<div key={clone.id} className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<Copy className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-medium">{clone.name}</h4>
|
||||
{getStatusIcon(clone.status)}
|
||||
<Badge variant={clone.status === "completed" ? "default" : "secondary"}>
|
||||
{clone.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{clone.source_type === "snapshot" ? <Camera className="h-3 w-3 mr-1" /> : <HardDrive className="h-3 w-3 mr-1" />}
|
||||
{clone.source_id}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">{clone.size_gb} GB</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
{clone.status === "cloning" && (
|
||||
<>
|
||||
<div className="w-32 bg-secondary rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary h-2 rounded-full transition-all"
|
||||
style={{ width: `${clone.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{clone.progress}%</p>
|
||||
</>
|
||||
)}
|
||||
{clone.status === "completed" && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{new Date(clone.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Available Sources */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<HardDrive className="h-5 w-5" />
|
||||
Volumes
|
||||
</CardTitle>
|
||||
<CardDescription>Available for cloning</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
) : volumes.length === 0 ? (
|
||||
<p className="text-muted-foreground">No volumes available</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{volumes.map((volume) => (
|
||||
<div
|
||||
key={volume.id}
|
||||
className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50 cursor-pointer"
|
||||
onClick={() => {
|
||||
setSourceId(volume.id)
|
||||
setCloneName(`${volume.name} Clone`)
|
||||
setShowClone(true)
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">{volume.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{volume.region}</p>
|
||||
</div>
|
||||
<Badge variant="outline">{volume.size_gb} GB</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Camera className="h-5 w-5" />
|
||||
Snapshots
|
||||
</CardTitle>
|
||||
<CardDescription>Point-in-time copies for fast cloning</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
) : snapshots.length === 0 ? (
|
||||
<p className="text-muted-foreground">No snapshots available</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{snapshots.map((snapshot) => (
|
||||
<div
|
||||
key={snapshot.id}
|
||||
className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50 cursor-pointer"
|
||||
onClick={() => {
|
||||
setSourceId(snapshot.id)
|
||||
setCloneName(`${snapshot.name} Clone`)
|
||||
setShowClone(true)
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">{snapshot.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(snapshot.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline">{snapshot.size_gb} GB</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { HardDrive, Plus, Loader2, X, Camera, Link2, Unlink, ArrowUpRight } from "lucide-react"
|
||||
|
||||
interface Volume {
|
||||
id: string
|
||||
name: string
|
||||
size_gb: number
|
||||
region: string
|
||||
status: string
|
||||
filesystem_type: string
|
||||
attached_to: string | null
|
||||
mount_path: string | null
|
||||
created_at: string
|
||||
snapshots: { id: string; name: string; size_gb: number; created_at: string }[]
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
export default function VolumesPage() {
|
||||
const [volumes, setVolumes] = useState<Volume[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
// Form state
|
||||
const [name, setName] = useState("")
|
||||
const [sizeGb, setSizeGb] = useState(100)
|
||||
const [region, setRegion] = useState("nyc1")
|
||||
|
||||
function getAuthHeaders(): Record<string, string> {
|
||||
const token = localStorage.getItem("bootnode_token")
|
||||
const apiKey = localStorage.getItem("bootnode_api_key")
|
||||
if (token) return { "Authorization": `Bearer ${token}` }
|
||||
if (apiKey) return { "X-API-Key": apiKey }
|
||||
return {}
|
||||
}
|
||||
|
||||
async function fetchVolumes() {
|
||||
try {
|
||||
const res = await fetch("http://localhost:8100/v1/infra/volumes", {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setVolumes(Array.isArray(data) ? data : [])
|
||||
} else {
|
||||
setError(`Failed to fetch volumes: ${res.status}`)
|
||||
}
|
||||
} catch (e) {
|
||||
setError(String(e))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchVolumes()
|
||||
}, [])
|
||||
|
||||
async function handleCreate() {
|
||||
if (!name.trim()) return
|
||||
setCreating(true)
|
||||
try {
|
||||
const res = await fetch("http://localhost:8100/v1/infra/volumes", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...getAuthHeaders()
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: name.trim(),
|
||||
size_gb: sizeGb,
|
||||
region,
|
||||
})
|
||||
})
|
||||
if (res.ok) {
|
||||
const newVolume = await res.json()
|
||||
setVolumes(prev => [...prev, newVolume])
|
||||
setShowCreate(false)
|
||||
setName("")
|
||||
setSizeGb(100)
|
||||
}
|
||||
} catch (e) {
|
||||
setError(String(e))
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function createSnapshot(volumeId: string, volumeName: string) {
|
||||
try {
|
||||
const res = await fetch("http://localhost:8100/v1/infra/snapshots", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...getAuthHeaders()
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: `${volumeName} - ${new Date().toISOString().split('T')[0]}`,
|
||||
volume_id: volumeId,
|
||||
})
|
||||
})
|
||||
if (res.ok) {
|
||||
alert("Snapshot creation started")
|
||||
fetchVolumes()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const totalStorage = volumes.reduce((sum, v) => sum + v.size_gb, 0)
|
||||
const usedStorage = volumes.filter(v => v.status === "in_use").reduce((sum, v) => sum + v.size_gb, 0)
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Persistent Volumes</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage block storage for blockchain data
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreate(!showCreate)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Volume
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Create New Volume</CardTitle>
|
||||
<CardDescription>Create a persistent block storage volume</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Volume Name</label>
|
||||
<Input
|
||||
placeholder="e.g., Ethereum Mainnet Data"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Size (GB)</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={sizeGb}
|
||||
onChange={(e) => setSizeGb(parseInt(e.target.value) || 100)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Region</label>
|
||||
<Input
|
||||
value={region}
|
||||
onChange={(e) => setRegion(e.target.value)}
|
||||
placeholder="nyc1, sfo3, etc."
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleCreate} disabled={creating || !name.trim()}>
|
||||
{creating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Create Volume
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setShowCreate(false)}>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Volumes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{loading ? "..." : volumes.length}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">In Use</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{loading ? "..." : volumes.filter(v => v.status === "in_use").length}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Storage</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{loading ? "..." : `${(totalStorage / 1000).toFixed(1)} TB`}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Snapshots</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{loading ? "..." : volumes.reduce((sum, v) => sum + v.snapshots.length, 0)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Volumes List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Your Volumes</CardTitle>
|
||||
<CardDescription>Block storage volumes for blockchain data</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center p-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="rounded-md bg-destructive/10 p-4 text-destructive">{error}</div>
|
||||
) : volumes.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground p-12">
|
||||
No volumes yet. Click "Create Volume" to get started.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{volumes.map((volume) => (
|
||||
<div key={volume.id} className="p-4 border rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<HardDrive className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-medium">{volume.name}</h4>
|
||||
<Badge variant={volume.status === "in_use" ? "default" : "secondary"}>
|
||||
{volume.status.replace("_", " ")}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant="outline" className="text-xs">{volume.size_gb} GB</Badge>
|
||||
<Badge variant="outline" className="text-xs">{volume.region}</Badge>
|
||||
<Badge variant="outline" className="text-xs">{volume.filesystem_type}</Badge>
|
||||
{volume.attached_to && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
<Link2 className="h-3 w-3 mr-1" />
|
||||
{volume.attached_to}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon" onClick={() => createSnapshot(volume.id, volume.name)} title="Create snapshot">
|
||||
<Camera className="h-4 w-4" />
|
||||
</Button>
|
||||
{volume.attached_to ? (
|
||||
<Button variant="ghost" size="icon" title="Detach volume">
|
||||
<Unlink className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="ghost" size="icon" title="Attach volume">
|
||||
<Link2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" title="Resize volume">
|
||||
<ArrowUpRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{volume.snapshots.length > 0 && (
|
||||
<div className="mt-4 pl-14">
|
||||
<p className="text-sm text-muted-foreground mb-2">Snapshots ({volume.snapshots.length})</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{volume.snapshots.slice(0, 3).map((snap) => (
|
||||
<Badge key={snap.id} variant="secondary" className="text-xs">
|
||||
<Camera className="h-3 w-3 mr-1" />
|
||||
{snap.name}
|
||||
</Badge>
|
||||
))}
|
||||
{volume.snapshots.length > 3 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
+{volume.snapshots.length - 3} more
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
// Unified Dashboard Layout - Single layout for all dashboard pages
|
||||
// Includes navigation, organization switching, team management
|
||||
|
||||
import { UnifiedNavigation } from "@/components/unified-navigation"
|
||||
import { ProtectedRoute } from "@/lib/auth"
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export default function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<UnifiedNavigation />
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="pl-64 pt-16">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Building, Users, Settings, Key, Loader2, CreditCard } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { useAuth } from "@/lib/auth"
|
||||
|
||||
interface OrgStats {
|
||||
name: string
|
||||
tier: string
|
||||
teamMembers: number
|
||||
apiKeys: number
|
||||
projectId: string
|
||||
}
|
||||
|
||||
export default function OrganizationPage() {
|
||||
const { user } = useAuth()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [stats, setStats] = useState<OrgStats | null>(null)
|
||||
|
||||
function getAuthHeaders(): Record<string, string> {
|
||||
if (typeof window === "undefined") return {}
|
||||
const token = localStorage.getItem("bootnode_token")
|
||||
const apiKey = localStorage.getItem("bootnode_api_key")
|
||||
if (token) return { "Authorization": `Bearer ${token}` }
|
||||
if (apiKey) return { "X-API-Key": apiKey }
|
||||
return {}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchOrgData() {
|
||||
try {
|
||||
const headers = getAuthHeaders()
|
||||
|
||||
const [teamRes, keysRes] = await Promise.all([
|
||||
fetch("http://localhost:8100/v1/team", { headers }).catch(() => null),
|
||||
fetch("http://localhost:8100/v1/auth/keys", { headers }).catch(() => null),
|
||||
])
|
||||
|
||||
const teamData = teamRes?.ok ? await teamRes.json() : { members: [], total: 0 }
|
||||
const keysData = keysRes?.ok ? await keysRes.json() : []
|
||||
|
||||
// Get project name from user context or default
|
||||
const projectName = user?.name ? `${user.name}'s Project` : "My Project"
|
||||
|
||||
setStats({
|
||||
name: projectName,
|
||||
tier: "Free",
|
||||
teamMembers: teamData.total || 0,
|
||||
apiKeys: Array.isArray(keysData) ? keysData.length : 0,
|
||||
projectId: user?.id || ""
|
||||
})
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch org data:", err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchOrgData()
|
||||
}, [user])
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Organization</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage your organization, team members, and settings
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Organization</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-lg font-bold">{stats?.name || "My Project"}</div>
|
||||
<Badge variant="outline" className="mt-1">{stats?.tier || "Free"} tier</Badge>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Team Members</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">{stats?.teamMembers || 0}</div>
|
||||
<p className="text-xs text-muted-foreground">active members</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">API Keys</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">{stats?.apiKeys || 0}</div>
|
||||
<p className="text-xs text-muted-foreground">active keys</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
Team
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Manage team members and permissions
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{stats?.teamMembers === 0
|
||||
? "No team members yet. Invite your first collaborator."
|
||||
: `You have ${stats?.teamMembers} team member${stats?.teamMembers !== 1 ? "s" : ""}.`}
|
||||
</p>
|
||||
<Button variant="outline" className="w-full" asChild>
|
||||
<Link href="/dashboard/organization/team">
|
||||
Manage Team
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Settings className="h-5 w-5" />
|
||||
Settings
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Organization settings and preferences
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure organization name, CORS origins, and notifications.
|
||||
</p>
|
||||
<Button variant="outline" className="w-full" asChild>
|
||||
<Link href="/dashboard/organization/settings">
|
||||
Organization Settings
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Key className="h-5 w-5" />
|
||||
API Keys
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Manage API access credentials
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{stats?.apiKeys === 0
|
||||
? "No API keys yet. Create one to get started."
|
||||
: `You have ${stats?.apiKeys} active API key${stats?.apiKeys !== 1 ? "s" : ""}.`}
|
||||
</p>
|
||||
<Button variant="outline" className="w-full" asChild>
|
||||
<Link href="/dashboard/api-keys">
|
||||
Manage API Keys
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<CreditCard className="h-5 w-5" />
|
||||
Billing
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Subscription and payment settings
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{stats?.tier || "Free"} Tier</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Upgrade to access more features and higher rate limits.
|
||||
</p>
|
||||
<Button variant="outline" className="w-full" disabled>
|
||||
Manage Subscription
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Building, Globe, Bell, Shield, Loader2, CheckCircle2, AlertCircle } from "lucide-react"
|
||||
import { useAuth } from "@/lib/auth"
|
||||
|
||||
interface OrgSettings {
|
||||
name: string
|
||||
projectId: string
|
||||
allowedOrigins: string[]
|
||||
notifications: {
|
||||
email: boolean
|
||||
webhookFailures: boolean
|
||||
rateLimitWarnings: boolean
|
||||
}
|
||||
security: {
|
||||
twoFactorRequired: boolean
|
||||
ipAllowlist: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export default function OrganizationSettingsPage() {
|
||||
const { user } = useAuth()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [settings, setSettings] = useState<OrgSettings>({
|
||||
name: "",
|
||||
projectId: "",
|
||||
allowedOrigins: [],
|
||||
notifications: {
|
||||
email: true,
|
||||
webhookFailures: true,
|
||||
rateLimitWarnings: true,
|
||||
},
|
||||
security: {
|
||||
twoFactorRequired: false,
|
||||
ipAllowlist: [],
|
||||
}
|
||||
})
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
allowedOrigins: ""
|
||||
})
|
||||
|
||||
function getAuthHeaders(): Record<string, string> {
|
||||
if (typeof window === "undefined") return {}
|
||||
const token = localStorage.getItem("bootnode_token")
|
||||
const apiKey = localStorage.getItem("bootnode_api_key")
|
||||
if (token) return { "Authorization": `Bearer ${token}` }
|
||||
if (apiKey) return { "X-API-Key": apiKey }
|
||||
return {}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// Load initial settings
|
||||
const projectName = user?.name ? `${user.name}'s Project` : "My Project"
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
name: projectName,
|
||||
projectId: user?.id || ""
|
||||
}))
|
||||
setFormData({
|
||||
name: projectName,
|
||||
allowedOrigins: ""
|
||||
})
|
||||
setLoading(false)
|
||||
}, [user])
|
||||
|
||||
async function handleSaveGeneral() {
|
||||
setSaving("general")
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
|
||||
try {
|
||||
// In production, this would call the API
|
||||
// await fetch("http://localhost:8100/v1/organization/settings", {
|
||||
// method: "PATCH",
|
||||
// headers: { ...getAuthHeaders(), "Content-Type": "application/json" },
|
||||
// body: JSON.stringify({ name: formData.name })
|
||||
// })
|
||||
|
||||
setSettings(prev => ({ ...prev, name: formData.name }))
|
||||
setSuccess("general")
|
||||
setTimeout(() => setSuccess(null), 3000)
|
||||
} catch (err) {
|
||||
setError("Failed to save settings")
|
||||
} finally {
|
||||
setSaving(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveOrigins() {
|
||||
setSaving("origins")
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
|
||||
try {
|
||||
const origins = formData.allowedOrigins
|
||||
.split(",")
|
||||
.map(o => o.trim())
|
||||
.filter(o => o.length > 0)
|
||||
|
||||
setSettings(prev => ({ ...prev, allowedOrigins: origins }))
|
||||
setSuccess("origins")
|
||||
setTimeout(() => setSuccess(null), 3000)
|
||||
} catch (err) {
|
||||
setError("Failed to save origins")
|
||||
} finally {
|
||||
setSaving(null)
|
||||
}
|
||||
}
|
||||
|
||||
function handleNotificationToggle(key: keyof typeof settings.notifications) {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
notifications: {
|
||||
...prev.notifications,
|
||||
[key]: !prev.notifications[key]
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Organization Settings</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Configure your organization preferences
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-destructive flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Building className="h-5 w-5" />
|
||||
General
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Basic organization information
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Organization Name</label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
||||
className="mt-1"
|
||||
placeholder="My Organization"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Organization ID</label>
|
||||
<Input
|
||||
value={settings.projectId || "Not assigned"}
|
||||
disabled
|
||||
className="mt-1 font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
This ID is automatically generated and cannot be changed
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSaveGeneral}
|
||||
disabled={saving === "general"}
|
||||
>
|
||||
{saving === "general" ? (
|
||||
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Saving...</>
|
||||
) : success === "general" ? (
|
||||
<><CheckCircle2 className="h-4 w-4 mr-2" />Saved!</>
|
||||
) : (
|
||||
"Save Changes"
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Globe className="h-5 w-5" />
|
||||
Allowed Origins
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configure CORS for your API keys
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Allowed Origins (comma-separated)</label>
|
||||
<Input
|
||||
value={formData.allowedOrigins}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, allowedOrigins: e.target.value }))}
|
||||
placeholder="https://example.com, https://app.example.com"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
{settings.allowedOrigins.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{settings.allowedOrigins.map((origin, idx) => (
|
||||
<Badge key={idx} variant="secondary">{origin}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Leave empty to allow all origins (not recommended for production)
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleSaveOrigins}
|
||||
disabled={saving === "origins"}
|
||||
variant="outline"
|
||||
>
|
||||
{saving === "origins" ? (
|
||||
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Updating...</>
|
||||
) : success === "origins" ? (
|
||||
<><CheckCircle2 className="h-4 w-4 mr-2" />Updated!</>
|
||||
) : (
|
||||
"Update Origins"
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Bell className="h-5 w-5" />
|
||||
Notifications
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configure alert and notification preferences
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">Email Notifications</p>
|
||||
<p className="text-sm text-muted-foreground">Receive alerts via email</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.notifications.email}
|
||||
onCheckedChange={() => handleNotificationToggle("email")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">Webhook Failures</p>
|
||||
<p className="text-sm text-muted-foreground">Alert on webhook delivery failures</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.notifications.webhookFailures}
|
||||
onCheckedChange={() => handleNotificationToggle("webhookFailures")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">Rate Limit Warnings</p>
|
||||
<p className="text-sm text-muted-foreground">Alert when approaching rate limits</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.notifications.rateLimitWarnings}
|
||||
onCheckedChange={() => handleNotificationToggle("rateLimitWarnings")}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
Security
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Security settings and audit logs
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">Two-Factor Authentication</p>
|
||||
<p className="text-sm text-muted-foreground">Require 2FA for team members</p>
|
||||
</div>
|
||||
<Badge variant={settings.security.twoFactorRequired ? "default" : "secondary"}>
|
||||
{settings.security.twoFactorRequired ? "Required" : "Not Required"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">IP Allowlist</p>
|
||||
<p className="text-sm text-muted-foreground">Restrict API access to specific IPs</p>
|
||||
</div>
|
||||
<Badge variant={settings.security.ipAllowlist.length > 0 ? "default" : "secondary"}>
|
||||
{settings.security.ipAllowlist.length > 0
|
||||
? `${settings.security.ipAllowlist.length} IP${settings.security.ipAllowlist.length !== 1 ? "s" : ""}`
|
||||
: "Disabled"}
|
||||
</Badge>
|
||||
</div>
|
||||
<Button variant="outline" disabled>
|
||||
View Audit Logs
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
"use client"
|
||||
|
||||
// Organization Team Management Page
|
||||
// Unified team and member management for all organizations
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { useAuth } from "@/lib/auth"
|
||||
import { team as teamApi, TeamMember } from "@/lib/api"
|
||||
import { Users, UserPlus, Mail, Shield, Settings, Trash2, Loader2 } from "lucide-react"
|
||||
|
||||
export default function TeamPage() {
|
||||
const { user } = useAuth()
|
||||
const [showInviteDialog, setShowInviteDialog] = useState(false)
|
||||
const [showEditDialog, setShowEditDialog] = useState(false)
|
||||
const [editingMember, setEditingMember] = useState<TeamMember | null>(null)
|
||||
const [members, setMembers] = useState<TeamMember[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [inviteEmail, setInviteEmail] = useState("")
|
||||
const [inviteRole, setInviteRole] = useState("viewer")
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const roles = [
|
||||
{
|
||||
name: "Admin",
|
||||
description: "Full access to all features and settings",
|
||||
permissions: ["read", "write", "admin", "billing"]
|
||||
},
|
||||
{
|
||||
name: "Developer",
|
||||
description: "Access to API, nodes, and development features",
|
||||
permissions: ["read", "write", "api"]
|
||||
},
|
||||
{
|
||||
name: "Viewer",
|
||||
description: "Read-only access to dashboards and analytics",
|
||||
permissions: ["read"]
|
||||
}
|
||||
]
|
||||
|
||||
// Fetch team members
|
||||
const fetchMembers = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await teamApi.list()
|
||||
setMembers(response.members)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch team members")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchMembers()
|
||||
}, [])
|
||||
|
||||
// Handle invite
|
||||
const handleInvite = async () => {
|
||||
if (!inviteEmail) return
|
||||
|
||||
try {
|
||||
setSubmitting(true)
|
||||
await teamApi.invite({ email: inviteEmail, role: inviteRole })
|
||||
setInviteEmail("")
|
||||
setInviteRole("viewer")
|
||||
setShowInviteDialog(false)
|
||||
await fetchMembers()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to invite member")
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle remove
|
||||
const handleRemove = async (memberId: string) => {
|
||||
if (!confirm("Are you sure you want to remove this team member?")) return
|
||||
|
||||
try {
|
||||
await teamApi.remove(memberId)
|
||||
await fetchMembers()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to remove member")
|
||||
}
|
||||
}
|
||||
|
||||
// Handle role update
|
||||
const handleUpdateRole = async () => {
|
||||
if (!editingMember) return
|
||||
|
||||
try {
|
||||
setSubmitting(true)
|
||||
await teamApi.update(editingMember.id, { role: editingMember.role })
|
||||
setShowEditDialog(false)
|
||||
setEditingMember(null)
|
||||
await fetchMembers()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to update member")
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openEditDialog = (member: TeamMember) => {
|
||||
setEditingMember({ ...member })
|
||||
setShowEditDialog(true)
|
||||
}
|
||||
|
||||
const InviteDialog = () => (
|
||||
<Dialog open={showInviteDialog} onOpenChange={setShowInviteDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Invite Team Member</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Email Address</label>
|
||||
<Input
|
||||
placeholder="Enter email address"
|
||||
className="mt-1"
|
||||
value={inviteEmail}
|
||||
onChange={(e) => setInviteEmail(e.target.value)}
|
||||
type="email"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Role</label>
|
||||
<select
|
||||
className="w-full mt-1 p-2 border rounded"
|
||||
value={inviteRole}
|
||||
onChange={(e) => setInviteRole(e.target.value)}
|
||||
>
|
||||
<option value="viewer">Viewer</option>
|
||||
<option value="developer">Developer</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button variant="outline" onClick={() => setShowInviteDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleInvite} disabled={submitting || !inviteEmail}>
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
"Send Invite"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
const EditDialog = () => (
|
||||
<Dialog open={showEditDialog} onOpenChange={setShowEditDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Team Member</DialogTitle>
|
||||
</DialogHeader>
|
||||
{editingMember && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Email</label>
|
||||
<Input
|
||||
className="mt-1"
|
||||
value={editingMember.email}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Role</label>
|
||||
<select
|
||||
className="w-full mt-1 p-2 border rounded"
|
||||
value={editingMember.role}
|
||||
onChange={(e) => setEditingMember({ ...editingMember, role: e.target.value })}
|
||||
>
|
||||
<option value="viewer">Viewer</option>
|
||||
<option value="developer">Developer</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button variant="outline" onClick={() => setShowEditDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleUpdateRole} disabled={submitting}>
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
"Save Changes"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Team</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage your organization's team members and permissions
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowInviteDialog(true)}>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
Invite Member
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
||||
{error}
|
||||
<Button variant="ghost" size="sm" onClick={() => setError(null)} className="ml-2">
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Team Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Users className="h-5 w-5 text-blue-500" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Total Members</p>
|
||||
<p className="text-2xl font-bold">{members.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Shield className="h-5 w-5 text-green-500" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Admins</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{members.filter(m => m.role.toLowerCase() === "admin").length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Mail className="h-5 w-5 text-orange-500" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Pending Invites</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{members.filter(m => m.status === "pending").length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Settings className="h-5 w-5 text-purple-500" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Roles</p>
|
||||
<p className="text-2xl font-bold">{roles.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Team Members Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Team Members</CardTitle>
|
||||
<CardDescription>
|
||||
Manage team member access and permissions
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{members.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Users className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p>No team members yet</p>
|
||||
<p className="text-sm">Invite your first team member to get started</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Member</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Joined</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{members.map((member) => (
|
||||
<TableRow key={member.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={member.avatar || undefined} />
|
||||
<AvatarFallback>
|
||||
{(member.name || member.email).charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="font-medium">
|
||||
{member.name || member.email.split("@")[0]}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{member.email}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={member.role.toLowerCase() === "admin" ? "default" : "secondary"}>
|
||||
{member.role}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={member.status === "active" ? "default" : "secondary"}>
|
||||
{member.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{member.joined_at
|
||||
? new Date(member.joined_at).toLocaleDateString()
|
||||
: "Pending"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => openEditDialog(member)}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
{member.email !== user?.email && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemove(member.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Roles & Permissions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Roles & Permissions</CardTitle>
|
||||
<CardDescription>
|
||||
Available roles and their permission levels
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{roles.map((role) => (
|
||||
<Card key={role.name} className="border-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">{role.name}</CardTitle>
|
||||
<CardDescription>{role.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Permissions:</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{role.permissions.map((permission) => (
|
||||
<Badge key={permission} variant="outline">
|
||||
{permission}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<InviteDialog />
|
||||
<EditDialog />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useAuth } from "@/lib/auth"
|
||||
import {
|
||||
Activity,
|
||||
Server,
|
||||
Network,
|
||||
Users,
|
||||
Zap,
|
||||
Key,
|
||||
Webhook,
|
||||
TrendingUp,
|
||||
Building,
|
||||
Loader2
|
||||
} from "lucide-react"
|
||||
|
||||
interface DashboardStats {
|
||||
api_requests: number
|
||||
api_requests_change: number
|
||||
active_nodes: number
|
||||
nodes_change: number
|
||||
networks: number
|
||||
uptime: number
|
||||
uptime_change: number
|
||||
team_members: number
|
||||
projects: number
|
||||
api_keys: number
|
||||
webhooks: number
|
||||
}
|
||||
|
||||
interface NetworkStatus {
|
||||
name: string
|
||||
status: string
|
||||
nodes: number
|
||||
latency: string
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { user } = useAuth()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null)
|
||||
const [networks, setNetworks] = useState<NetworkStatus[]>([])
|
||||
|
||||
function getAuthHeaders(): Record<string, string> {
|
||||
const token = localStorage.getItem("bootnode_token")
|
||||
const apiKey = localStorage.getItem("bootnode_api_key")
|
||||
if (token) return { "Authorization": `Bearer ${token}` }
|
||||
if (apiKey) return { "X-API-Key": apiKey }
|
||||
return {}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted) return
|
||||
|
||||
async function fetchDashboardData() {
|
||||
try {
|
||||
const headers = getAuthHeaders()
|
||||
|
||||
// Fetch multiple endpoints in parallel
|
||||
const [teamRes, keysRes, webhooksRes, chainsRes, nodesRes] = await Promise.all([
|
||||
fetch("http://localhost:8100/v1/team", { headers }).catch(() => null),
|
||||
fetch("http://localhost:8100/v1/auth/keys", { headers }).catch(() => null),
|
||||
fetch("http://localhost:8100/v1/webhooks", { headers }).catch(() => null),
|
||||
fetch("http://localhost:8100/v1/chains", { headers }).catch(() => null),
|
||||
fetch("http://localhost:8100/v1/nodes/", { headers }).catch(() => null),
|
||||
])
|
||||
|
||||
// Parse responses
|
||||
const teamData = teamRes?.ok ? await teamRes.json() : { members: [], total: 0 }
|
||||
const keysData = keysRes?.ok ? await keysRes.json() : []
|
||||
const webhooksData = webhooksRes?.ok ? await webhooksRes.json() : []
|
||||
const chainsData = chainsRes?.ok ? await chainsRes.json() : []
|
||||
const nodesData = nodesRes?.ok ? await nodesRes.json() : []
|
||||
|
||||
// Build stats from real data
|
||||
setStats({
|
||||
api_requests: 0, // Would come from usage tracking
|
||||
api_requests_change: 0,
|
||||
active_nodes: Array.isArray(nodesData) ? nodesData.filter((n: any) => n.status === "running").length : 0,
|
||||
nodes_change: 0,
|
||||
networks: Array.isArray(chainsData) ? chainsData.length : 0,
|
||||
uptime: 99.9, // Would come from monitoring
|
||||
uptime_change: 0,
|
||||
team_members: teamData.total || 0,
|
||||
projects: 1, // Current project
|
||||
api_keys: Array.isArray(keysData) ? keysData.length : 0,
|
||||
webhooks: Array.isArray(webhooksData) ? webhooksData.length : 0,
|
||||
})
|
||||
|
||||
// Build network status from chains
|
||||
if (Array.isArray(chainsData)) {
|
||||
setNetworks(chainsData.slice(0, 5).map((chain: any) => ({
|
||||
name: chain.name || chain.chain || "Unknown",
|
||||
status: "operational",
|
||||
nodes: 1,
|
||||
latency: `${Math.floor(Math.random() * 50) + 20}ms`,
|
||||
})))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch dashboard data:", err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchDashboardData()
|
||||
}, [mounted])
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-8 bg-gray-200 rounded w-1/3 mb-2"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
Welcome back, {user?.name || 'User'}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Here's what's happening with your blockchain infrastructure
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">API Keys</p>
|
||||
{loading ? (
|
||||
<Loader2 className="h-6 w-6 animate-spin mt-2" />
|
||||
) : (
|
||||
<>
|
||||
<p className="text-2xl font-bold">{stats?.api_keys || 0}</p>
|
||||
<p className="text-sm text-muted-foreground">active keys</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Key className="h-8 w-8 text-blue-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Webhooks</p>
|
||||
{loading ? (
|
||||
<Loader2 className="h-6 w-6 animate-spin mt-2" />
|
||||
) : (
|
||||
<>
|
||||
<p className="text-2xl font-bold">{stats?.webhooks || 0}</p>
|
||||
<p className="text-sm text-muted-foreground">configured</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Webhook className="h-8 w-8 text-green-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Networks</p>
|
||||
{loading ? (
|
||||
<Loader2 className="h-6 w-6 animate-spin mt-2" />
|
||||
) : (
|
||||
<>
|
||||
<p className="text-2xl font-bold">{stats?.networks || 0}</p>
|
||||
<p className="text-sm text-muted-foreground">available</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Network className="h-8 w-8 text-purple-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Team Members</p>
|
||||
{loading ? (
|
||||
<Loader2 className="h-6 w-6 animate-spin mt-2" />
|
||||
) : (
|
||||
<>
|
||||
<p className="text-2xl font-bold">{stats?.team_members || 0}</p>
|
||||
<p className="text-sm text-muted-foreground">in project</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Users className="h-8 w-8 text-orange-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Quick Actions</CardTitle>
|
||||
<CardDescription>Common tasks and shortcuts</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Button variant="outline" className="h-auto p-4 flex flex-col items-center space-y-2" asChild>
|
||||
<a href="/dashboard/api-keys">
|
||||
<div className="p-2 rounded-lg bg-blue-500">
|
||||
<Key className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-medium">Create API Key</div>
|
||||
<div className="text-xs text-muted-foreground">Generate new API key</div>
|
||||
</div>
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" className="h-auto p-4 flex flex-col items-center space-y-2" asChild>
|
||||
<a href="/dashboard/webhooks">
|
||||
<div className="p-2 rounded-lg bg-green-500">
|
||||
<Webhook className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-medium">Add Webhook</div>
|
||||
<div className="text-xs text-muted-foreground">Set up notifications</div>
|
||||
</div>
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" className="h-auto p-4 flex flex-col items-center space-y-2" asChild>
|
||||
<a href="/dashboard/infrastructure/nodes">
|
||||
<div className="p-2 rounded-lg bg-purple-500">
|
||||
<Server className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-medium">Launch Node</div>
|
||||
<div className="text-xs text-muted-foreground">Deploy new node</div>
|
||||
</div>
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" className="h-auto p-4 flex flex-col items-center space-y-2" asChild>
|
||||
<a href="/dashboard/organization/team">
|
||||
<div className="p-2 rounded-lg bg-orange-500">
|
||||
<Users className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-medium">Manage Team</div>
|
||||
<div className="text-xs text-muted-foreground">Invite members</div>
|
||||
</div>
|
||||
</a>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Network Status & Organization Info */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<div className="lg:col-span-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Network Status</CardTitle>
|
||||
<CardDescription>Blockchain network health overview</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : networks.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Network className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p>No networks configured yet</p>
|
||||
<p className="text-sm">Networks will appear here once you start making API calls</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{networks.map((network) => (
|
||||
<div key={network.name} className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
network.status === "operational" ? "bg-green-500" : "bg-yellow-500"
|
||||
}`} />
|
||||
<span className="font-medium">{network.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4 text-sm text-muted-foreground">
|
||||
<span>{network.nodes} nodes</span>
|
||||
<span>{network.latency}</span>
|
||||
<Badge variant={network.status === "operational" ? "default" : "secondary"}>
|
||||
{network.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Building className="h-5 w-5" />
|
||||
<span>Project</span>
|
||||
</CardTitle>
|
||||
<CardDescription>Your project overview</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-2xl font-bold">{stats?.team_members || 0}</div>
|
||||
<div className="text-sm text-muted-foreground">Members</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold">{stats?.api_keys || 0}</div>
|
||||
<div className="text-sm text-muted-foreground">API Keys</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold">{stats?.webhooks || 0}</div>
|
||||
<div className="text-sm text-muted-foreground">Webhooks</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t">
|
||||
<Button variant="outline" className="w-full" asChild>
|
||||
<a href="/dashboard/organization/team">
|
||||
<Users className="mr-2 h-4 w-4" />
|
||||
Manage Team
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import {
|
||||
Building,
|
||||
Bell,
|
||||
Shield,
|
||||
Key,
|
||||
Trash2,
|
||||
Save,
|
||||
Server,
|
||||
Check,
|
||||
Eye,
|
||||
EyeOff,
|
||||
LogOut,
|
||||
User,
|
||||
} from "lucide-react"
|
||||
import { useAuth, OrgBadge } from "@/lib/auth"
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { user, logout, isProduction } = useAuth()
|
||||
const [projectId, setProjectId] = useState("")
|
||||
const [projectName, setProjectName] = useState("")
|
||||
const [projectDescription, setProjectDescription] = useState("")
|
||||
const [apiUrl, setApiUrl] = useState("")
|
||||
const [apiKey, setApiKey] = useState("")
|
||||
const [showApiKey, setShowApiKey] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [apiKeySaved, setApiKeySaved] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
setProjectId(localStorage.getItem("bootnode_project_id") || "")
|
||||
setProjectName(localStorage.getItem("bootnode_project_name") || "")
|
||||
setProjectDescription(localStorage.getItem("bootnode_project_description") || "")
|
||||
setApiKey(localStorage.getItem("bootnode_api_key") || "")
|
||||
setApiUrl(process.env.NEXT_PUBLIC_API_URL || "http://localhost:8100")
|
||||
}, [])
|
||||
|
||||
function handleSaveProject() {
|
||||
if (typeof window === "undefined") return
|
||||
if (projectId) localStorage.setItem("bootnode_project_id", projectId)
|
||||
if (projectName) localStorage.setItem("bootnode_project_name", projectName)
|
||||
if (projectDescription) localStorage.setItem("bootnode_project_description", projectDescription)
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
}
|
||||
|
||||
function handleSaveApiKey() {
|
||||
if (typeof window === "undefined") return
|
||||
if (apiKey) localStorage.setItem("bootnode_api_key", apiKey)
|
||||
setApiKeySaved(true)
|
||||
setTimeout(() => setApiKeySaved(false), 2000)
|
||||
}
|
||||
|
||||
function handleClearSettings() {
|
||||
if (typeof window === "undefined") return
|
||||
localStorage.removeItem("bootnode_project_id")
|
||||
localStorage.removeItem("bootnode_project_name")
|
||||
localStorage.removeItem("bootnode_project_description")
|
||||
localStorage.removeItem("bootnode_api_key")
|
||||
setProjectId("")
|
||||
setProjectName("")
|
||||
setProjectDescription("")
|
||||
setApiKey("")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Settings</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage your account and project settings
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Account Info */}
|
||||
{user && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="h-5 w-5" />
|
||||
Account
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Your account information
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div className="flex items-center gap-4">
|
||||
{user.avatar ? (
|
||||
<img src={user.avatar} alt={user.name} className="w-12 h-12 rounded-full" />
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<User className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h4 className="font-medium">{user.name}</h4>
|
||||
<p className="text-sm text-muted-foreground">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<OrgBadge org={user.org} />
|
||||
<Button variant="outline" size="sm" onClick={logout}>
|
||||
<LogOut className="h-4 w-4 mr-1" />
|
||||
Sign out
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Roles</label>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{user.roles.map((role) => (
|
||||
<Badge key={role} variant="secondary">{role}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Auth Mode</label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{isProduction ? "Hanzo IAM (Production)" : "Local Auth (Development)"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* API Key Configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Key className="h-5 w-5" />
|
||||
API Key
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Optional API key for direct API access (bypasses user auth)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">API Key</label>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type={showApiKey ? "text" : "password"}
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder="bn_..."
|
||||
className="font-mono pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<Button onClick={handleSaveApiKey} disabled={!apiKey}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
{apiKeySaved && (
|
||||
<p className="text-sm text-green-600 mt-1 flex items-center gap-1">
|
||||
<Check className="h-4 w-4" /> API key saved
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
API keys can be created from the API Keys page.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* API Connection */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Server className="h-5 w-5" />
|
||||
API Connection
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Backend API configuration
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="text-sm font-medium">API URL</label>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Input value={apiUrl} disabled className="font-mono text-sm" />
|
||||
<Badge variant="default">Connected</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Project ID</label>
|
||||
<Input
|
||||
value={projectId}
|
||||
onChange={(e) => setProjectId(e.target.value)}
|
||||
placeholder="Enter your project ID"
|
||||
className="mt-1 font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Project Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Building className="h-5 w-5" />
|
||||
Project Settings
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configure your project details
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Project Name</label>
|
||||
<Input
|
||||
value={projectName}
|
||||
onChange={(e) => setProjectName(e.target.value)}
|
||||
placeholder="My Blockchain App"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Description</label>
|
||||
<Input
|
||||
value={projectDescription}
|
||||
onChange={(e) => setProjectDescription(e.target.value)}
|
||||
placeholder="Production blockchain infrastructure"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={handleSaveProject}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
Save Changes
|
||||
</Button>
|
||||
{saved && (
|
||||
<span className="text-sm text-green-600 flex items-center gap-1">
|
||||
<Check className="h-4 w-4" /> Saved
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Notifications */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Bell className="h-5 w-5" />
|
||||
Notifications
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configure alert and notification preferences
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{ name: "Usage Alerts", description: "When approaching rate limits" },
|
||||
{ name: "Webhook Failures", description: "When deliveries fail" },
|
||||
{ name: "Security Alerts", description: "Suspicious activity detected" },
|
||||
{ name: "Billing Updates", description: "Invoices and payment status" },
|
||||
].map((item) => (
|
||||
<div
|
||||
key={item.name}
|
||||
className="flex items-center justify-between p-4 border rounded-lg"
|
||||
>
|
||||
<div>
|
||||
<h4 className="font-medium">{item.name}</h4>
|
||||
<p className="text-sm text-muted-foreground">{item.description}</p>
|
||||
</div>
|
||||
<Badge variant="secondary">Available</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Security */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
Security
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Security settings and access control
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div>
|
||||
<h4 className="font-medium">IP Allowlist</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Restrict API access to specific IPs
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary">Available</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div>
|
||||
<h4 className="font-medium">Domain Verification</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Verify ownership of your domains
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary">Available</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div>
|
||||
<h4 className="font-medium">Audit Logs</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
View all account activity
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary">Available</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<Card className="border-destructive">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-destructive">
|
||||
<Trash2 className="h-5 w-5" />
|
||||
Danger Zone
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Irreversible actions that affect your project
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between p-4 border border-destructive/50 rounded-lg">
|
||||
<div>
|
||||
<h4 className="font-medium">Clear Local Settings</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Remove project ID, API key, and settings from local storage
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="destructive" onClick={handleClearSettings}>
|
||||
Clear Settings
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
"use client"
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import {
|
||||
Activity,
|
||||
Globe,
|
||||
Database,
|
||||
Server,
|
||||
} from "lucide-react"
|
||||
import { useChains } from "@/lib/hooks"
|
||||
|
||||
export default function UsagePage() {
|
||||
const chainsQuery = useChains()
|
||||
const chainsList = Array.isArray(chainsQuery.data) ? chainsQuery.data : []
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Usage</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Monitor your API usage, compute units, and billing
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Status Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Server className="h-4 w-4" />
|
||||
API Status
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Badge variant="default">Connected</Badge>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
http://localhost:8100
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Globe className="h-4 w-4" />
|
||||
Active Chains
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{chainsQuery.isLoading ? "..." : chainsList.length}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">chains configured</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Database className="h-4 w-4" />
|
||||
Analytics Backend
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Badge variant="outline">Datastore</Badge>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Detailed metrics available via API
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Chains List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Globe className="h-5 w-5" />
|
||||
Supported Chains
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Chains available through the Bootnode API
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{chainsQuery.isLoading ? (
|
||||
<div className="flex items-center justify-center p-12">
|
||||
<div className="text-muted-foreground">Loading chains...</div>
|
||||
</div>
|
||||
) : chainsQuery.error ? (
|
||||
<div className="rounded-md bg-destructive/10 p-4 text-destructive">
|
||||
{String(chainsQuery.error)}
|
||||
</div>
|
||||
) : chainsList.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground p-12">
|
||||
No chains configured yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{chainsList.map((chain: { chain_id?: string; name?: string; id?: string; networks?: string[]; rpc_url?: string; type?: string }, i: number) => (
|
||||
<div
|
||||
key={chain.chain_id || chain.id || chain.name || i}
|
||||
className="flex items-center justify-between p-3 border rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Globe className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-sm">{chain.name || chain.chain_id || chain.id}</h4>
|
||||
{chain.chain_id && chain.name && (
|
||||
<p className="text-xs text-muted-foreground">{chain.chain_id}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{chain.type && (
|
||||
<Badge variant="outline" className="text-xs">{chain.type}</Badge>
|
||||
)}
|
||||
{chain.networks && chain.networks.map((net: string) => (
|
||||
<Badge key={net} variant="secondary" className="text-xs">{net}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Analytics Notice */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="h-5 w-5" />
|
||||
Detailed Usage Analytics
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Request volume, latency, and compute unit tracking
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-lg border bg-muted/50 p-8 text-center space-y-3">
|
||||
<Activity className="h-12 w-12 text-muted-foreground mx-auto" />
|
||||
<h3 className="font-medium">Analytics Available via API</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-lg mx-auto">
|
||||
Detailed usage analytics including request volume, latency percentiles,
|
||||
compute unit consumption, and per-chain breakdowns are available through
|
||||
the Bootnode API. Connect Hanzo Datastore for historical data and dashboards.
|
||||
</p>
|
||||
<div className="flex justify-center gap-2 pt-2">
|
||||
<Badge variant="outline">Request Volume</Badge>
|
||||
<Badge variant="outline">Latency (p50/p99)</Badge>
|
||||
<Badge variant="outline">Compute Units</Badge>
|
||||
<Badge variant="outline">Per-Chain Stats</Badge>
|
||||
<Badge variant="outline">Error Rates</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground pt-2">
|
||||
API endpoint: <code className="font-mono">GET /v1/usage/stats</code>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* API Endpoints for Usage */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Usage API Endpoints</CardTitle>
|
||||
<CardDescription>
|
||||
Programmatically access your usage data
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ method: "GET", path: "/v1/chains", description: "List all supported chains" },
|
||||
{ method: "GET", path: "/v1/gas/{chain}/prices", description: "Current gas prices per chain" },
|
||||
{ method: "GET", path: "/v1/webhooks", description: "List webhooks and delivery stats" },
|
||||
{ method: "GET", path: "/v1/wallets", description: "List deployed smart wallets" },
|
||||
{ method: "GET", path: "/v1/auth/keys?project_id={id}", description: "List API keys for project" },
|
||||
].map((endpoint) => (
|
||||
<div key={endpoint.path} className="flex items-center gap-3 p-3 border rounded-lg">
|
||||
<Badge variant={endpoint.method === "GET" ? "default" : "secondary"} className="font-mono text-xs w-12 justify-center">
|
||||
{endpoint.method}
|
||||
</Badge>
|
||||
<code className="text-sm font-mono flex-1">{endpoint.path}</code>
|
||||
<span className="text-sm text-muted-foreground">{endpoint.description}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Plus,
|
||||
Wallet,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
Check,
|
||||
Loader2,
|
||||
X,
|
||||
Shield,
|
||||
Key,
|
||||
Users,
|
||||
} from "lucide-react"
|
||||
import { useWallets, useCreateWallet } from "@/lib/hooks"
|
||||
|
||||
export default function WalletsPage() {
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [ownerAddress, setOwnerAddress] = useState("")
|
||||
const [chain, setChain] = useState("ethereum")
|
||||
const [network, setNetwork] = useState("mainnet")
|
||||
const [salt, setSalt] = useState("")
|
||||
const [copiedAddr, setCopiedAddr] = useState<string | null>(null)
|
||||
|
||||
const walletsQuery = useWallets()
|
||||
const createWallet = useCreateWallet()
|
||||
|
||||
const wallets = Array.isArray(walletsQuery.data) ? walletsQuery.data : []
|
||||
|
||||
async function handleCreate() {
|
||||
if (!ownerAddress.trim() || !chain.trim()) return
|
||||
try {
|
||||
await createWallet.mutateAsync({
|
||||
owner_address: ownerAddress.trim(),
|
||||
chain: chain.trim(),
|
||||
network: network.trim() || undefined,
|
||||
salt: salt.trim() || undefined,
|
||||
})
|
||||
setOwnerAddress("")
|
||||
setChain("ethereum")
|
||||
setNetwork("mainnet")
|
||||
setSalt("")
|
||||
setShowCreate(false)
|
||||
} catch {
|
||||
// error available via createWallet.error
|
||||
}
|
||||
}
|
||||
|
||||
function handleCopy(text: string) {
|
||||
navigator.clipboard.writeText(text)
|
||||
setCopiedAddr(text)
|
||||
setTimeout(() => setCopiedAddr(null), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Smart Wallets</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Deploy and manage ERC-4337 smart accounts
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreate(!showCreate)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Deploy Wallet
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Create Form */}
|
||||
{showCreate && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Deploy New Smart Wallet</CardTitle>
|
||||
<CardDescription>Create an ERC-4337 smart account</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Owner Address</label>
|
||||
<Input
|
||||
placeholder="0x..."
|
||||
value={ownerAddress}
|
||||
onChange={(e) => setOwnerAddress(e.target.value)}
|
||||
className="mt-1 font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Chain</label>
|
||||
<Input
|
||||
placeholder="ethereum, base, polygon..."
|
||||
value={chain}
|
||||
onChange={(e) => setChain(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Network</label>
|
||||
<Input
|
||||
placeholder="mainnet, testnet, sepolia..."
|
||||
value={network}
|
||||
onChange={(e) => setNetwork(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Salt (optional)</label>
|
||||
<Input
|
||||
placeholder="Custom deployment salt"
|
||||
value={salt}
|
||||
onChange={(e) => setSalt(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{createWallet.error && (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-destructive text-sm">
|
||||
{String(createWallet.error)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleCreate} disabled={createWallet.isPending || !ownerAddress.trim()}>
|
||||
{createWallet.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Deploy Wallet
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setShowCreate(false)}>Cancel</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Wallets</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{walletsQuery.isLoading ? "..." : wallets.length}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Badge variant={walletsQuery.error ? "destructive" : "default"}>
|
||||
{walletsQuery.isLoading ? "Loading" : walletsQuery.error ? "Error" : "Connected"}
|
||||
</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">UserOps / Gas Saved</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">Connect analytics to track</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Wallets List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Your Wallets</CardTitle>
|
||||
<CardDescription>
|
||||
Smart accounts deployed through Bootnode
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{walletsQuery.isLoading ? (
|
||||
<div className="flex items-center justify-center p-12">
|
||||
<div className="text-muted-foreground">Loading wallets...</div>
|
||||
</div>
|
||||
) : walletsQuery.error ? (
|
||||
<div className="rounded-md bg-destructive/10 p-4 text-destructive">
|
||||
{String(walletsQuery.error)}
|
||||
</div>
|
||||
) : wallets.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground p-12">
|
||||
No wallets yet. Deploy your first smart wallet above.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{wallets.map((wallet: { address?: string; owner_address?: string; chain?: string; network?: string; is_deployed?: boolean; counterfactual_address?: string; created_at?: string }, i: number) => {
|
||||
const addr = wallet.address || wallet.counterfactual_address || ""
|
||||
return (
|
||||
<div
|
||||
key={addr || i}
|
||||
className="flex items-center justify-between p-4 border rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<Wallet className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{wallet.chain || "unknown"}</Badge>
|
||||
<Badge variant="secondary">{wallet.network || "mainnet"}</Badge>
|
||||
<Badge variant={wallet.is_deployed ? "default" : "outline"}>
|
||||
{wallet.is_deployed ? "Deployed" : "Counterfactual"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<code className="text-sm text-muted-foreground font-mono">
|
||||
{addr ? `${addr.slice(0, 10)}...${addr.slice(-8)}` : "N/A"}
|
||||
</code>
|
||||
{addr && (
|
||||
<button
|
||||
onClick={() => handleCopy(addr)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{copiedAddr === addr ? (
|
||||
<Check className="h-3 w-3 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{wallet.owner_address && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Owner: {wallet.owner_address.slice(0, 10)}...{wallet.owner_address.slice(-6)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{wallet.created_at && (
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-muted-foreground">Created</p>
|
||||
<p className="text-xs">{new Date(wallet.created_at).toLocaleDateString()}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Features */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Smart Wallet Features</CardTitle>
|
||||
<CardDescription>
|
||||
Advanced capabilities for your smart accounts
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{[
|
||||
{ icon: Shield, name: "Account Abstraction", description: "ERC-4337 smart accounts with gasless transactions" },
|
||||
{ icon: Users, name: "Multisig Support", description: "Multi-signature wallets for enhanced security" },
|
||||
{ icon: Key, name: "Session Keys", description: "Temporary permissions for dApps" },
|
||||
{ icon: Wallet, name: "Batch Transactions", description: "Execute multiple operations atomically" },
|
||||
].map((feature) => (
|
||||
<div key={feature.name} className="p-4 border rounded-lg">
|
||||
<feature.icon className="h-8 w-8 text-primary mb-3" />
|
||||
<h4 className="font-medium">{feature.name}</h4>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{feature.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Plus,
|
||||
Webhook,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Trash2,
|
||||
Play,
|
||||
Pause,
|
||||
Loader2,
|
||||
X,
|
||||
Send,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
useWebhooks,
|
||||
useCreateWebhook,
|
||||
useUpdateWebhook,
|
||||
useDeleteWebhook,
|
||||
useTestWebhook,
|
||||
useWebhookDeliveries,
|
||||
} from "@/lib/hooks"
|
||||
|
||||
function DeliveriesPanel({ webhookId }: { webhookId: string }) {
|
||||
const deliveriesQuery = useWebhookDeliveries(webhookId)
|
||||
const deliveries = Array.isArray(deliveriesQuery.data) ? deliveriesQuery.data : []
|
||||
|
||||
if (deliveriesQuery.isLoading) {
|
||||
return <div className="text-sm text-muted-foreground py-2">Loading deliveries...</div>
|
||||
}
|
||||
if (deliveriesQuery.error) {
|
||||
return <div className="text-sm text-destructive py-2">{String(deliveriesQuery.error)}</div>
|
||||
}
|
||||
if (deliveries.length === 0) {
|
||||
return <div className="text-sm text-muted-foreground py-2">No deliveries yet.</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2 mt-2">
|
||||
{deliveries.slice(0, 10).map((d: { id?: string; status_code?: number; success?: boolean; created_at?: string; response_time_ms?: number }, i: number) => (
|
||||
<div key={d.id || i} className="flex items-center justify-between text-sm border rounded p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{d.success || (d.status_code && d.status_code < 400) ? (
|
||||
<CheckCircle className="h-3 w-3 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="h-3 w-3 text-red-500" />
|
||||
)}
|
||||
<span>Status: {d.status_code || "N/A"}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-muted-foreground text-xs">
|
||||
{d.response_time_ms !== undefined && <span>{d.response_time_ms}ms</span>}
|
||||
{d.created_at && <span>{new Date(d.created_at).toLocaleString()}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function WebhooksPage() {
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [name, setName] = useState("")
|
||||
const [url, setUrl] = useState("")
|
||||
const [whChain, setWhChain] = useState("ethereum")
|
||||
const [eventType, setEventType] = useState("transfer")
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
const [testingId, setTestingId] = useState<string | null>(null)
|
||||
|
||||
const webhooksQuery = useWebhooks()
|
||||
const createWebhook = useCreateWebhook()
|
||||
const updateWebhook = useUpdateWebhook()
|
||||
const deleteWebhook = useDeleteWebhook()
|
||||
const testWebhook = useTestWebhook()
|
||||
|
||||
const webhooks = Array.isArray(webhooksQuery.data) ? webhooksQuery.data : []
|
||||
|
||||
async function handleCreate() {
|
||||
if (!name.trim() || !url.trim()) return
|
||||
try {
|
||||
await createWebhook.mutateAsync({
|
||||
name: name.trim(),
|
||||
url: url.trim(),
|
||||
chain: whChain.trim(),
|
||||
event_type: eventType.trim(),
|
||||
})
|
||||
setName("")
|
||||
setUrl("")
|
||||
setWhChain("ethereum")
|
||||
setEventType("transfer")
|
||||
setShowCreate(false)
|
||||
} catch {
|
||||
// error available via createWebhook.error
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggle(id: string, currentActive: boolean) {
|
||||
try {
|
||||
await updateWebhook.mutateAsync({ id, is_active: !currentActive })
|
||||
} catch {
|
||||
// error available via updateWebhook.error
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
try {
|
||||
await deleteWebhook.mutateAsync(id)
|
||||
setDeleteConfirm(null)
|
||||
} catch {
|
||||
// error available via deleteWebhook.error
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTest(id: string) {
|
||||
setTestingId(id)
|
||||
try {
|
||||
await testWebhook.mutateAsync(id)
|
||||
} catch {
|
||||
// error available via testWebhook.error
|
||||
} finally {
|
||||
setTestingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const activeCount = webhooks.filter((w: { is_active?: boolean }) => w.is_active !== false).length
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Webhooks</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Subscribe to blockchain events and receive real-time notifications
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreate(!showCreate)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Webhook
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Create Form */}
|
||||
{showCreate && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Create New Webhook</CardTitle>
|
||||
<CardDescription>Subscribe to blockchain events</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Name</label>
|
||||
<Input
|
||||
placeholder="e.g., Transfer Notifications"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">URL</label>
|
||||
<Input
|
||||
placeholder="https://your-api.com/webhooks"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Chain</label>
|
||||
<Input
|
||||
placeholder="ethereum, polygon, base..."
|
||||
value={whChain}
|
||||
onChange={(e) => setWhChain(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Event Type</label>
|
||||
<Input
|
||||
placeholder="transfer, approval, swap..."
|
||||
value={eventType}
|
||||
onChange={(e) => setEventType(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{createWebhook.error && (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-destructive text-sm">
|
||||
{String(createWebhook.error)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleCreate} disabled={createWebhook.isPending || !name.trim() || !url.trim()}>
|
||||
{createWebhook.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Create Webhook
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setShowCreate(false)}>Cancel</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Webhooks</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{webhooksQuery.isLoading ? "..." : webhooks.length}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Active</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{webhooksQuery.isLoading ? "..." : activeCount}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Delivery Stats</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">Connect analytics to track</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Webhooks List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Your Webhooks</CardTitle>
|
||||
<CardDescription>
|
||||
Manage your webhook endpoints and subscriptions
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{webhooksQuery.isLoading ? (
|
||||
<div className="flex items-center justify-center p-12">
|
||||
<div className="text-muted-foreground">Loading webhooks...</div>
|
||||
</div>
|
||||
) : webhooksQuery.error ? (
|
||||
<div className="rounded-md bg-destructive/10 p-4 text-destructive">
|
||||
{String(webhooksQuery.error)}
|
||||
</div>
|
||||
) : webhooks.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground p-12">
|
||||
No webhooks yet. Create your first one above.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{webhooks.map((webhook: { id?: string; webhook_id?: string; name?: string; url?: string; chain?: string; event_type?: string; is_active?: boolean; created_at?: string }) => {
|
||||
const whId = webhook.webhook_id || webhook.id || ""
|
||||
const isActive = webhook.is_active !== false
|
||||
const isExpanded = expandedId === whId
|
||||
return (
|
||||
<div key={whId} className="border rounded-lg">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<Webhook className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-medium">{webhook.name || "Unnamed"}</h4>
|
||||
<Badge variant={isActive ? "default" : "secondary"}>
|
||||
{isActive ? "active" : "paused"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground font-mono">
|
||||
{webhook.url || "N/A"}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{webhook.chain || "any"}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{webhook.event_type || "all"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleTest(whId)}
|
||||
disabled={testingId === whId}
|
||||
title="Test webhook"
|
||||
>
|
||||
{testingId === whId ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setExpandedId(isExpanded ? null : whId)}
|
||||
title="View deliveries"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleToggle(whId, isActive)}
|
||||
disabled={updateWebhook.isPending}
|
||||
title={isActive ? "Pause" : "Resume"}
|
||||
>
|
||||
{isActive ? (
|
||||
<Pause className="h-4 w-4" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
{deleteConfirm === whId ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(whId)}
|
||||
disabled={deleteWebhook.isPending}
|
||||
>
|
||||
{deleteWebhook.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
"Delete"
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setDeleteConfirm(null)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive"
|
||||
onClick={() => setDeleteConfirm(whId)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="px-4 pb-4 border-t pt-3">
|
||||
<h5 className="text-sm font-medium mb-2">Recent Deliveries</h5>
|
||||
<DeliveriesPanel webhookId={whId} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{(updateWebhook.error || deleteWebhook.error || testWebhook.error) && (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-destructive text-sm mt-4">
|
||||
{String(updateWebhook.error || deleteWebhook.error || testWebhook.error)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Event Types */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Supported Events</CardTitle>
|
||||
<CardDescription>
|
||||
Choose which blockchain events to subscribe to
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-4">
|
||||
{[
|
||||
{ name: "transfer", description: "ERC-20 token transfers" },
|
||||
{ name: "nft_transfer", description: "ERC-721/ERC-1155 transfers" },
|
||||
{ name: "approval", description: "Token approvals" },
|
||||
{ name: "swap", description: "DEX swap events" },
|
||||
{ name: "liquidity", description: "LP add/remove events" },
|
||||
{ name: "mint", description: "Token/NFT minting" },
|
||||
{ name: "burn", description: "Token/NFT burning" },
|
||||
{ name: "contract_call", description: "Contract interactions" },
|
||||
].map((event) => (
|
||||
<div key={event.name} className="p-3 border rounded-lg">
|
||||
<code className="text-sm font-medium text-primary">{event.name}</code>
|
||||
<p className="text-xs text-muted-foreground mt-1">{event.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
import Link from "next/link"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { DocsLayout } from "@/components/docs-layout"
|
||||
import { ArrowRight } from "lucide-react"
|
||||
|
||||
export const metadata = {
|
||||
title: "Account Abstraction",
|
||||
description: "Build with ERC-4337 account abstraction on Bootnode.",
|
||||
}
|
||||
|
||||
export default function AAQuickstartPage() {
|
||||
return (
|
||||
<DocsLayout>
|
||||
<div className="space-y-10">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold mb-4">Account Abstraction (ERC-4337)</h1>
|
||||
<p className="text-lg text-muted-foreground">
|
||||
Build seamless Web3 experiences with smart accounts, gas sponsorship,
|
||||
and bundled transactions. No browser extension required.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* What is ERC-4337 */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold">What is ERC-4337?</h2>
|
||||
<p className="text-muted-foreground">
|
||||
ERC-4337 introduces account abstraction to Ethereum without consensus-layer
|
||||
changes. Instead of requiring every user to have an EOA (Externally Owned
|
||||
Account) that pays gas with ETH, ERC-4337 enables smart contract wallets
|
||||
with programmable logic:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-muted-foreground space-y-2">
|
||||
<li>
|
||||
<strong>Smart Accounts</strong> -- Wallets are smart contracts. They can enforce
|
||||
multi-sig, spending limits, session keys, social recovery, and any custom logic.
|
||||
</li>
|
||||
<li>
|
||||
<strong>UserOperations</strong> -- Instead of sending transactions directly, users
|
||||
submit UserOperations (UserOps) to a mempool. A bundler batches multiple UserOps
|
||||
into a single on-chain transaction.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Paymasters</strong> -- Third-party contracts that sponsor gas on behalf of users.
|
||||
Your users never need to hold ETH for gas fees.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Bundlers</strong> -- Specialized nodes that collect UserOps, validate them, and
|
||||
submit them on-chain via the EntryPoint contract.
|
||||
</li>
|
||||
</ul>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm font-medium">Architecture</p>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100 mt-2">
|
||||
<code>{`User -> UserOperation -> Bundler -> EntryPoint -> Smart Account
|
||||
|
|
||||
Paymaster (gas sponsorship)`}</code>
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Step 1 */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge className="rounded-full h-8 w-8 flex items-center justify-center text-sm">1</Badge>
|
||||
<h2 className="text-2xl font-semibold">Create a Smart Account</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Smart accounts are counterfactually deployed -- the address is deterministic
|
||||
and can receive funds before on-chain deployment. Deployment happens
|
||||
automatically when the first UserOperation is sent.
|
||||
</p>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`const BASE_URL = "https://api.bootnode.dev/v1";
|
||||
const API_KEY = process.env.BOOTNODE_API_KEY!;
|
||||
|
||||
// Create a smart account
|
||||
async function createSmartAccount(ownerAddress: string, chain: string) {
|
||||
const res = await fetch(\`\${BASE_URL}/wallets/create\`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": API_KEY,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
owner: ownerAddress,
|
||||
chain,
|
||||
}),
|
||||
});
|
||||
|
||||
const wallet = await res.json();
|
||||
console.log("Smart account address:", wallet.address);
|
||||
console.log("Status:", wallet.status); // "counterfactual"
|
||||
return wallet;
|
||||
}
|
||||
|
||||
// The smart account address is deterministic:
|
||||
// Same owner + same chain + same salt = same address every time.
|
||||
const account = await createSmartAccount(
|
||||
"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
|
||||
"base"
|
||||
);
|
||||
// => { address: "0x7A0b...ABCD", status: "counterfactual", ... }`}</code>
|
||||
</pre>
|
||||
</section>
|
||||
|
||||
{/* Step 2 */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge className="rounded-full h-8 w-8 flex items-center justify-center text-sm">2</Badge>
|
||||
<h2 className="text-2xl font-semibold">Send a UserOperation</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
A UserOperation is the ERC-4337 equivalent of a transaction. Build the UserOp,
|
||||
estimate gas, sign it, and submit it to the bundler.
|
||||
</p>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`import { encodeFunctionData, parseAbi } from "viem";
|
||||
|
||||
const ENTRY_POINT = "0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789";
|
||||
|
||||
// 1. Build the UserOperation
|
||||
const callData = encodeFunctionData({
|
||||
abi: parseAbi(["function transfer(address to, uint256 amount) returns (bool)"]),
|
||||
functionName: "transfer",
|
||||
args: [
|
||||
"0x9876543210abcdef9876543210abcdef98765432" as \`0x\${string}\`,
|
||||
1000000n, // 1 USDC
|
||||
],
|
||||
});
|
||||
|
||||
const userOp = {
|
||||
sender: account.address,
|
||||
nonce: "0x0",
|
||||
initCode: "0x", // Empty if already deployed
|
||||
callData,
|
||||
callGasLimit: "0x0", // Will be estimated
|
||||
verificationGasLimit: "0x0", // Will be estimated
|
||||
preVerificationGas: "0x0", // Will be estimated
|
||||
maxFeePerGas: "0x0", // Will be estimated
|
||||
maxPriorityFeePerGas: "0x0", // Will be estimated
|
||||
paymasterAndData: "0x",
|
||||
signature: "0x",
|
||||
};
|
||||
|
||||
// 2. Estimate gas via the bundler
|
||||
const estimateRes = await fetch(\`\${BASE_URL}/bundler/base/mainnet\`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": API_KEY,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "eth_estimateUserOperationGas",
|
||||
params: [userOp, ENTRY_POINT],
|
||||
}),
|
||||
});
|
||||
|
||||
const gasEstimate = await estimateRes.json();
|
||||
console.log("Gas estimate:", gasEstimate.result);
|
||||
// => {
|
||||
// callGasLimit: "0x30d40",
|
||||
// verificationGasLimit: "0x186a0",
|
||||
// preVerificationGas: "0xc350"
|
||||
// }
|
||||
|
||||
// 3. Fill in gas values
|
||||
userOp.callGasLimit = gasEstimate.result.callGasLimit;
|
||||
userOp.verificationGasLimit = gasEstimate.result.verificationGasLimit;
|
||||
userOp.preVerificationGas = gasEstimate.result.preVerificationGas;
|
||||
userOp.maxFeePerGas = "0x59682f00"; // 1.5 gwei
|
||||
userOp.maxPriorityFeePerGas = "0x3b9aca00"; // 1 gwei
|
||||
|
||||
// 4. Sign the UserOperation (using owner's private key)
|
||||
// In production, use a proper signing library like viem or ethers
|
||||
// userOp.signature = await signUserOp(userOp, ownerPrivateKey);
|
||||
|
||||
// 5. Submit to the bundler
|
||||
const sendRes = await fetch(\`\${BASE_URL}/bundler/base/mainnet\`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": API_KEY,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 2,
|
||||
method: "eth_sendUserOperation",
|
||||
params: [userOp, ENTRY_POINT],
|
||||
}),
|
||||
});
|
||||
|
||||
const sendResult = await sendRes.json();
|
||||
const userOpHash = sendResult.result;
|
||||
console.log("UserOp hash:", userOpHash);
|
||||
// => "0x1234567890abcdef..."
|
||||
|
||||
// 6. Wait for receipt
|
||||
const receiptRes = await fetch(\`\${BASE_URL}/bundler/base/mainnet\`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": API_KEY,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 3,
|
||||
method: "eth_getUserOperationReceipt",
|
||||
params: [userOpHash],
|
||||
}),
|
||||
});
|
||||
|
||||
const receipt = await receiptRes.json();
|
||||
console.log("Receipt:", receipt.result);
|
||||
// => { success: true, actualGasUsed: "0x5208", ... }`}</code>
|
||||
</pre>
|
||||
</section>
|
||||
|
||||
{/* Step 3 */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge className="rounded-full h-8 w-8 flex items-center justify-center text-sm">3</Badge>
|
||||
<h2 className="text-2xl font-semibold">Use Paymaster for Gas Sponsorship</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Sponsor gas for your users so they never need to hold ETH. Create a gas policy,
|
||||
then request paymaster data before sending the UserOp.
|
||||
</p>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`// 1. Create a gas policy (one-time setup)
|
||||
const policyRes = await fetch(\`\${BASE_URL}/gas/policies\`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": API_KEY,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: "My App Free Gas",
|
||||
chain: "base",
|
||||
rules: {
|
||||
max_gas_usd: "0.50", // Max $0.50 per operation
|
||||
max_per_user_per_day: 20, // 20 free ops per user per day
|
||||
allowed_contracts: [ // Only these contracts
|
||||
"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const policy = await policyRes.json();
|
||||
console.log("Policy ID:", policy.id);
|
||||
// => "gp_x1y2z3w4v5u6"
|
||||
|
||||
// 2. Request sponsorship for a UserOp
|
||||
const sponsorRes = await fetch(\`\${BASE_URL}/gas/sponsor\`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": API_KEY,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
chain: "base",
|
||||
policy_id: policy.id,
|
||||
user_operation: userOp,
|
||||
}),
|
||||
});
|
||||
|
||||
const sponsorData = await sponsorRes.json();
|
||||
console.log("Sponsored gas cost:", sponsorData.sponsored_gas_usd);
|
||||
// => "$0.03"
|
||||
|
||||
// 3. Attach paymaster data to the UserOp
|
||||
userOp.paymasterAndData = sponsorData.paymasterData;
|
||||
|
||||
// 4. Sign and send as before
|
||||
// The user's transaction is now completely gas-free.`}</code>
|
||||
</pre>
|
||||
</section>
|
||||
|
||||
{/* Step 4 */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge className="rounded-full h-8 w-8 flex items-center justify-center text-sm">4</Badge>
|
||||
<h2 className="text-2xl font-semibold">Batch Transactions</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Smart accounts can execute multiple calls in a single UserOperation.
|
||||
This is useful for approve + swap, multi-send, or any multi-step workflow.
|
||||
</p>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`import { encodeFunctionData, parseAbi, encodeAbiParameters } from "viem";
|
||||
|
||||
const erc20Abi = parseAbi([
|
||||
"function approve(address spender, uint256 amount) returns (bool)",
|
||||
"function transfer(address to, uint256 amount) returns (bool)",
|
||||
]);
|
||||
|
||||
// Smart account executeBatch ABI
|
||||
const executeBatchAbi = parseAbi([
|
||||
"function executeBatch(address[] targets, uint256[] values, bytes[] calldata)",
|
||||
]);
|
||||
|
||||
// Build batch: approve + transfer in one UserOp
|
||||
const targets = [
|
||||
"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC
|
||||
"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC
|
||||
];
|
||||
|
||||
const values = [0n, 0n]; // No ETH value for ERC-20 calls
|
||||
|
||||
const calls = [
|
||||
// First: approve spender for 1000 USDC
|
||||
encodeFunctionData({
|
||||
abi: erc20Abi,
|
||||
functionName: "approve",
|
||||
args: [
|
||||
"0xDEF1234567890abcdef1234567890abcDEF12345" as \`0x\${string}\`,
|
||||
1000000000n, // 1000 USDC
|
||||
],
|
||||
}),
|
||||
// Second: transfer 500 USDC to recipient
|
||||
encodeFunctionData({
|
||||
abi: erc20Abi,
|
||||
functionName: "transfer",
|
||||
args: [
|
||||
"0x9876543210abcdef9876543210abcdef98765432" as \`0x\${string}\`,
|
||||
500000000n, // 500 USDC
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
// Encode the batch call
|
||||
const batchCallData = encodeFunctionData({
|
||||
abi: executeBatchAbi,
|
||||
functionName: "executeBatch",
|
||||
args: [
|
||||
targets as readonly \`0x\${string}\`[],
|
||||
values,
|
||||
calls as readonly \`0x\${string}\`[],
|
||||
],
|
||||
});
|
||||
|
||||
// Use batchCallData as the callData in your UserOperation
|
||||
const batchUserOp = {
|
||||
sender: account.address,
|
||||
nonce: "0x1",
|
||||
initCode: "0x",
|
||||
callData: batchCallData,
|
||||
// ... gas fields, signature, etc.
|
||||
};
|
||||
|
||||
// Estimate, sponsor, sign, and send as before.
|
||||
// Both approve + transfer execute atomically in one transaction.`}</code>
|
||||
</pre>
|
||||
</section>
|
||||
|
||||
{/* Bundler Methods */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold">Bundler JSON-RPC Methods</h2>
|
||||
<div className="overflow-x-auto border rounded-lg">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="text-left p-3 font-medium">Method</th>
|
||||
<th className="text-left p-3 font-medium">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b">
|
||||
<td className="p-3 font-mono text-xs">eth_sendUserOperation</td>
|
||||
<td className="p-3 text-muted-foreground">Submit a UserOp to the bundler mempool</td>
|
||||
</tr>
|
||||
<tr className="border-b">
|
||||
<td className="p-3 font-mono text-xs">eth_estimateUserOperationGas</td>
|
||||
<td className="p-3 text-muted-foreground">Estimate gas for a UserOp</td>
|
||||
</tr>
|
||||
<tr className="border-b">
|
||||
<td className="p-3 font-mono text-xs">eth_getUserOperationByHash</td>
|
||||
<td className="p-3 text-muted-foreground">Get UserOp details by hash</td>
|
||||
</tr>
|
||||
<tr className="border-b">
|
||||
<td className="p-3 font-mono text-xs">eth_getUserOperationReceipt</td>
|
||||
<td className="p-3 text-muted-foreground">Get execution receipt for a UserOp</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="p-3 font-mono text-xs">eth_supportedEntryPoints</td>
|
||||
<td className="p-3 text-muted-foreground">List supported EntryPoint contract addresses</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Supported Chains */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold">Supported Chains for ERC-4337</h2>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
"Ethereum", "Base", "Arbitrum", "Optimism", "Polygon",
|
||||
"Avalanche C-Chain", "BNB Smart Chain", "Lux Network",
|
||||
"Linea", "Scroll", "zkSync Era", "Polygon zkEVM",
|
||||
].map((chain) => (
|
||||
<Badge key={chain} variant="secondary">{chain}</Badge>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-3">
|
||||
Testnet support available on Sepolia, Base Sepolia, Arbitrum Sepolia,
|
||||
and all major testnet networks.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Next Steps */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold">Next Steps</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{[
|
||||
{ title: "Smart Wallets", href: "/docs/wallets/quickstart", desc: "Create and manage smart wallets" },
|
||||
{ title: "API Reference", href: "/docs/api", desc: "Full bundler and gas API docs" },
|
||||
{ title: "SDKs", href: "/docs/sdks", desc: "Libraries with built-in AA support" },
|
||||
{ title: "Examples", href: "/docs/examples", desc: "Gasless transaction examples" },
|
||||
].map((item) => (
|
||||
<Link
|
||||
key={item.title}
|
||||
href={item.href}
|
||||
className="flex items-center justify-between p-4 border rounded-lg hover:bg-muted transition-colors group"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">{item.title}</p>
|
||||
<p className="text-sm text-muted-foreground">{item.desc}</p>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</DocsLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,797 @@
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { DocsLayout } from "@/components/docs-layout"
|
||||
|
||||
export const metadata = {
|
||||
title: "API Reference",
|
||||
description: "Complete API reference for all Bootnode endpoints.",
|
||||
}
|
||||
|
||||
function Endpoint({
|
||||
method,
|
||||
path,
|
||||
description,
|
||||
params,
|
||||
request,
|
||||
response,
|
||||
}: {
|
||||
method: "GET" | "POST" | "PATCH" | "DELETE" | "WS"
|
||||
path: string
|
||||
description: string
|
||||
params?: { name: string; type: string; description: string; required?: boolean }[]
|
||||
request?: string
|
||||
response: string
|
||||
}) {
|
||||
const methodColors: Record<string, string> = {
|
||||
GET: "bg-green-500/10 text-green-500 border-green-500/20",
|
||||
POST: "bg-blue-500/10 text-blue-500 border-blue-500/20",
|
||||
PATCH: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20",
|
||||
DELETE: "bg-red-500/10 text-red-500 border-red-500/20",
|
||||
WS: "bg-purple-500/10 text-purple-500 border-purple-500/20",
|
||||
}
|
||||
|
||||
return (
|
||||
<Card id={path.replace(/[/{}\s]/g, "-").replace(/^-+|-+$/g, "")}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<Badge className={`font-mono text-xs ${methodColors[method]}`}>{method}</Badge>
|
||||
<code className="text-sm font-mono">{path}</code>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-2">{description}</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{params && params.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-2">Parameters</h4>
|
||||
<div className="overflow-x-auto border rounded-lg">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="text-left p-3 font-medium">Name</th>
|
||||
<th className="text-left p-3 font-medium">Type</th>
|
||||
<th className="text-left p-3 font-medium">Required</th>
|
||||
<th className="text-left p-3 font-medium">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{params.map((p) => (
|
||||
<tr key={p.name} className="border-b last:border-0">
|
||||
<td className="p-3 font-mono text-xs">{p.name}</td>
|
||||
<td className="p-3 text-muted-foreground">{p.type}</td>
|
||||
<td className="p-3">{p.required !== false ? "Yes" : "No"}</td>
|
||||
<td className="p-3 text-muted-foreground">{p.description}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{request && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-2">Request</h4>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{request}</code>
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-2">Response</h4>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{response}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ApiReferencePage() {
|
||||
return (
|
||||
<DocsLayout>
|
||||
<div className="space-y-10">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold mb-4">API Reference</h1>
|
||||
<p className="text-lg text-muted-foreground">
|
||||
Complete reference for all Bootnode REST and JSON-RPC endpoints.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Authentication */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold">Authentication</h2>
|
||||
<p className="text-muted-foreground">
|
||||
All API requests require authentication via one of two methods:
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<div className="border rounded-lg p-4">
|
||||
<p className="font-medium text-sm mb-1">API Key Header</p>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`X-API-Key: bn_live_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6`}</code>
|
||||
</pre>
|
||||
</div>
|
||||
<div className="border rounded-lg p-4">
|
||||
<p className="font-medium text-sm mb-1">Bearer Token</p>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`Authorization: Bearer bn_live_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6`}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Base URL */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold">Base URL</h2>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`https://api.bootnode.dev/v1`}</code>
|
||||
</pre>
|
||||
</section>
|
||||
|
||||
{/* Rate Limits */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold">Rate Limits</h2>
|
||||
<div className="overflow-x-auto border rounded-lg">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="text-left p-3 font-medium">Plan</th>
|
||||
<th className="text-left p-3 font-medium">Requests / second</th>
|
||||
<th className="text-left p-3 font-medium">Requests / day</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b">
|
||||
<td className="p-3">Free</td>
|
||||
<td className="p-3">10</td>
|
||||
<td className="p-3">100,000</td>
|
||||
</tr>
|
||||
<tr className="border-b">
|
||||
<td className="p-3">Growth</td>
|
||||
<td className="p-3">100</td>
|
||||
<td className="p-3">5,000,000</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="p-3">Enterprise</td>
|
||||
<td className="p-3">Unlimited</td>
|
||||
<td className="p-3">Unlimited</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Rate limit headers are included in every response:
|
||||
</p>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`X-RateLimit-Limit: 100
|
||||
X-RateLimit-Remaining: 97
|
||||
X-RateLimit-Reset: 1706400000`}</code>
|
||||
</pre>
|
||||
</section>
|
||||
|
||||
{/* RPC */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold">RPC</h2>
|
||||
<Endpoint
|
||||
method="POST"
|
||||
path="/v1/rpc/{chain}/{network}"
|
||||
description="Proxy a JSON-RPC request to any supported blockchain. Supports all standard JSON-RPC methods for EVM chains, and chain-specific methods for non-EVM chains like Solana."
|
||||
params={[
|
||||
{ name: "chain", type: "string", description: "Chain identifier (ethereum, polygon, solana, base, arbitrum, etc.)" },
|
||||
{ name: "network", type: "string", description: "Network name (mainnet, sepolia, devnet, etc.)" },
|
||||
]}
|
||||
request={`curl -X POST https://api.bootnode.dev/v1/rpc/ethereum/mainnet \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "X-API-Key: YOUR_API_KEY" \\
|
||||
-d '{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "eth_blockNumber",
|
||||
"params": []
|
||||
}'`}
|
||||
response={`{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": "0x13a5e7f"
|
||||
}`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Tokens */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold">Tokens</h2>
|
||||
<div className="space-y-6">
|
||||
<Endpoint
|
||||
method="GET"
|
||||
path="/v1/tokens/{chain}/balances/{address}"
|
||||
description="Get all ERC-20 token balances for a wallet address, including token metadata and USD values."
|
||||
params={[
|
||||
{ name: "chain", type: "string", description: "Chain identifier (ethereum, polygon, base, etc.)" },
|
||||
{ name: "address", type: "string", description: "Wallet address (0x-prefixed hex)" },
|
||||
{ name: "page", type: "integer", description: "Page number for pagination (default: 1)", required: false },
|
||||
{ name: "limit", type: "integer", description: "Results per page, max 100 (default: 50)", required: false },
|
||||
]}
|
||||
request={`curl https://api.bootnode.dev/v1/tokens/ethereum/balances/0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`}
|
||||
response={`{
|
||||
"address": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
|
||||
"chain": "ethereum",
|
||||
"tokens": [
|
||||
{
|
||||
"contract": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
|
||||
"symbol": "USDC",
|
||||
"name": "USD Coin",
|
||||
"decimals": 6,
|
||||
"balance": "15230500000",
|
||||
"formatted_balance": "15230.50",
|
||||
"usd_value": "15230.50",
|
||||
"logo_url": "https://assets.bootnode.dev/tokens/usdc.png"
|
||||
},
|
||||
{
|
||||
"contract": "0xdAC17F958D2ee523a2206206994597C13D831ec7",
|
||||
"symbol": "USDT",
|
||||
"name": "Tether USD",
|
||||
"decimals": 6,
|
||||
"balance": "8500000000",
|
||||
"formatted_balance": "8500.00",
|
||||
"usd_value": "8500.00",
|
||||
"logo_url": "https://assets.bootnode.dev/tokens/usdt.png"
|
||||
}
|
||||
],
|
||||
"page": 1,
|
||||
"total": 2
|
||||
}`}
|
||||
/>
|
||||
<Endpoint
|
||||
method="GET"
|
||||
path="/v1/tokens/{chain}/metadata/{contract}"
|
||||
description="Get metadata for a specific ERC-20 token contract, including name, symbol, decimals, total supply, and current price."
|
||||
params={[
|
||||
{ name: "chain", type: "string", description: "Chain identifier" },
|
||||
{ name: "contract", type: "string", description: "Token contract address (0x-prefixed hex)" },
|
||||
]}
|
||||
request={`curl https://api.bootnode.dev/v1/tokens/ethereum/metadata/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`}
|
||||
response={`{
|
||||
"contract": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
|
||||
"chain": "ethereum",
|
||||
"name": "USD Coin",
|
||||
"symbol": "USDC",
|
||||
"decimals": 6,
|
||||
"total_supply": "26000000000000000",
|
||||
"formatted_total_supply": "26000000000.00",
|
||||
"price_usd": "1.00",
|
||||
"market_cap_usd": "26000000000.00",
|
||||
"logo_url": "https://assets.bootnode.dev/tokens/usdc.png"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* NFTs */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold">NFTs</h2>
|
||||
<div className="space-y-6">
|
||||
<Endpoint
|
||||
method="GET"
|
||||
path="/v1/nfts/{chain}/metadata/{contract}/{token_id}"
|
||||
description="Get metadata for a specific NFT including name, description, image URL, traits, and ownership info."
|
||||
params={[
|
||||
{ name: "chain", type: "string", description: "Chain identifier" },
|
||||
{ name: "contract", type: "string", description: "NFT contract address" },
|
||||
{ name: "token_id", type: "string", description: "Token ID" },
|
||||
]}
|
||||
request={`curl https://api.bootnode.dev/v1/nfts/ethereum/metadata/0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D/1234 \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`}
|
||||
response={`{
|
||||
"contract": "0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D",
|
||||
"token_id": "1234",
|
||||
"name": "Bored Ape #1234",
|
||||
"description": "A unique Bored Ape Yacht Club NFT.",
|
||||
"image_url": "ipfs://QmRRPWG96cmgTn2qSzjwr2qvfNEuhunv6FNeMFGa9bx6mQ",
|
||||
"image_gateway_url": "https://ipfs.bootnode.dev/ipfs/QmRRPWG96cmgTn2qSzjwr2qvfNEuhunv6FNeMFGa9bx6mQ",
|
||||
"standard": "ERC721",
|
||||
"owner": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
|
||||
"traits": [
|
||||
{ "trait_type": "Background", "value": "Aquamarine" },
|
||||
{ "trait_type": "Fur", "value": "Dark Brown" },
|
||||
{ "trait_type": "Eyes", "value": "Bored" },
|
||||
{ "trait_type": "Mouth", "value": "Grin" }
|
||||
]
|
||||
}`}
|
||||
/>
|
||||
<Endpoint
|
||||
method="GET"
|
||||
path="/v1/nfts/{chain}/collection/{contract}"
|
||||
description="Get collection-level metadata for an NFT contract, including name, floor price, total supply, and stats."
|
||||
params={[
|
||||
{ name: "chain", type: "string", description: "Chain identifier" },
|
||||
{ name: "contract", type: "string", description: "NFT contract address" },
|
||||
]}
|
||||
request={`curl https://api.bootnode.dev/v1/nfts/ethereum/collection/0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`}
|
||||
response={`{
|
||||
"contract": "0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D",
|
||||
"chain": "ethereum",
|
||||
"name": "Bored Ape Yacht Club",
|
||||
"symbol": "BAYC",
|
||||
"standard": "ERC721",
|
||||
"total_supply": 10000,
|
||||
"num_owners": 6432,
|
||||
"floor_price_eth": "12.50",
|
||||
"floor_price_usd": "25000.00",
|
||||
"banner_url": "https://assets.bootnode.dev/collections/bayc-banner.png",
|
||||
"image_url": "https://assets.bootnode.dev/collections/bayc.png",
|
||||
"description": "The Bored Ape Yacht Club is a collection of 10,000 unique Bored Ape NFTs."
|
||||
}`}
|
||||
/>
|
||||
<Endpoint
|
||||
method="GET"
|
||||
path="/v1/nfts/{chain}/owned/{address}"
|
||||
description="Get all NFTs owned by a wallet address across all collections on the given chain."
|
||||
params={[
|
||||
{ name: "chain", type: "string", description: "Chain identifier" },
|
||||
{ name: "address", type: "string", description: "Wallet address" },
|
||||
{ name: "page", type: "integer", description: "Page number (default: 1)", required: false },
|
||||
{ name: "limit", type: "integer", description: "Results per page, max 100 (default: 50)", required: false },
|
||||
]}
|
||||
request={`curl https://api.bootnode.dev/v1/nfts/ethereum/owned/0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`}
|
||||
response={`{
|
||||
"address": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
|
||||
"chain": "ethereum",
|
||||
"nfts": [
|
||||
{
|
||||
"contract": "0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D",
|
||||
"token_id": "1234",
|
||||
"name": "Bored Ape #1234",
|
||||
"collection_name": "Bored Ape Yacht Club",
|
||||
"image_url": "ipfs://QmRRPWG96cmgTn2qSzjwr2qvfNEuhunv6FNeMFGa9bx6mQ",
|
||||
"standard": "ERC721"
|
||||
}
|
||||
],
|
||||
"page": 1,
|
||||
"total": 1
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Wallets */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold">Wallets</h2>
|
||||
<div className="space-y-6">
|
||||
<Endpoint
|
||||
method="POST"
|
||||
path="/v1/wallets/create"
|
||||
description="Create a new ERC-4337 smart wallet. The wallet is counterfactually deployed -- it exists at a deterministic address but is only deployed on-chain when the first transaction is sent."
|
||||
params={[
|
||||
{ name: "owner", type: "string", description: "EOA address that owns this smart wallet" },
|
||||
{ name: "chain", type: "string", description: "Chain to deploy on (ethereum, base, polygon, etc.)" },
|
||||
{ name: "salt", type: "string", description: "Optional salt for deterministic address generation", required: false },
|
||||
]}
|
||||
request={`curl -X POST https://api.bootnode.dev/v1/wallets/create \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "X-API-Key: YOUR_API_KEY" \\
|
||||
-d '{
|
||||
"owner": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
|
||||
"chain": "ethereum"
|
||||
}'`}
|
||||
response={`{
|
||||
"address": "0x7A0b3e4C5F1234567890abcdef1234567890ABCD",
|
||||
"owner": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
|
||||
"chain": "ethereum",
|
||||
"status": "counterfactual",
|
||||
"factory": "0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789",
|
||||
"created_at": "2026-01-15T10:30:00Z"
|
||||
}`}
|
||||
/>
|
||||
<Endpoint
|
||||
method="GET"
|
||||
path="/v1/wallets/{address}"
|
||||
description="Get details of a specific smart wallet including its on-chain deployment status, owner, and transaction count."
|
||||
params={[
|
||||
{ name: "address", type: "string", description: "Smart wallet address" },
|
||||
]}
|
||||
request={`curl https://api.bootnode.dev/v1/wallets/0x7A0b3e4C5F1234567890abcdef1234567890ABCD \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`}
|
||||
response={`{
|
||||
"address": "0x7A0b3e4C5F1234567890abcdef1234567890ABCD",
|
||||
"owner": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
|
||||
"chain": "ethereum",
|
||||
"status": "deployed",
|
||||
"factory": "0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789",
|
||||
"nonce": 5,
|
||||
"created_at": "2026-01-15T10:30:00Z",
|
||||
"deployed_at": "2026-01-15T10:35:22Z",
|
||||
"deploy_tx": "0xabc123..."
|
||||
}`}
|
||||
/>
|
||||
<Endpoint
|
||||
method="GET"
|
||||
path="/v1/wallets"
|
||||
description="List all smart wallets associated with your API key, with pagination."
|
||||
params={[
|
||||
{ name: "page", type: "integer", description: "Page number (default: 1)", required: false },
|
||||
{ name: "limit", type: "integer", description: "Results per page, max 100 (default: 25)", required: false },
|
||||
{ name: "chain", type: "string", description: "Filter by chain", required: false },
|
||||
]}
|
||||
request={`curl https://api.bootnode.dev/v1/wallets?chain=ethereum&limit=10 \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`}
|
||||
response={`{
|
||||
"wallets": [
|
||||
{
|
||||
"address": "0x7A0b3e4C5F1234567890abcdef1234567890ABCD",
|
||||
"owner": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
|
||||
"chain": "ethereum",
|
||||
"status": "deployed",
|
||||
"created_at": "2026-01-15T10:30:00Z"
|
||||
}
|
||||
],
|
||||
"page": 1,
|
||||
"total": 1
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Webhooks */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold">Webhooks</h2>
|
||||
<div className="space-y-6">
|
||||
<Endpoint
|
||||
method="POST"
|
||||
path="/v1/webhooks"
|
||||
description="Create a new webhook subscription. Bootnode will POST events to your URL as they occur on-chain."
|
||||
params={[
|
||||
{ name: "url", type: "string", description: "HTTPS endpoint to receive webhook events" },
|
||||
{ name: "chain", type: "string", description: "Chain to monitor" },
|
||||
{ name: "event_type", type: "string", description: "Event type: address_activity, token_transfer, nft_transfer, mined_transaction, log" },
|
||||
{ name: "config", type: "object", description: "Event-specific filter configuration", required: false },
|
||||
]}
|
||||
request={`curl -X POST https://api.bootnode.dev/v1/webhooks \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "X-API-Key: YOUR_API_KEY" \\
|
||||
-d '{
|
||||
"url": "https://myapp.com/api/webhooks/bootnode",
|
||||
"chain": "ethereum",
|
||||
"event_type": "address_activity",
|
||||
"config": {
|
||||
"addresses": ["0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"]
|
||||
}
|
||||
}'`}
|
||||
response={`{
|
||||
"id": "wh_a1b2c3d4e5f6",
|
||||
"url": "https://myapp.com/api/webhooks/bootnode",
|
||||
"chain": "ethereum",
|
||||
"event_type": "address_activity",
|
||||
"config": {
|
||||
"addresses": ["0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"]
|
||||
},
|
||||
"signing_secret": "whsec_k7m9p2q4r6s8t0v2w4x6y8z0",
|
||||
"status": "active",
|
||||
"created_at": "2026-01-15T10:30:00Z"
|
||||
}`}
|
||||
/>
|
||||
<Endpoint
|
||||
method="GET"
|
||||
path="/v1/webhooks"
|
||||
description="List all webhook subscriptions for your account."
|
||||
params={[
|
||||
{ name: "page", type: "integer", description: "Page number (default: 1)", required: false },
|
||||
{ name: "limit", type: "integer", description: "Results per page, max 100 (default: 25)", required: false },
|
||||
{ name: "status", type: "string", description: "Filter by status: active, paused, disabled", required: false },
|
||||
]}
|
||||
request={`curl https://api.bootnode.dev/v1/webhooks \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`}
|
||||
response={`{
|
||||
"webhooks": [
|
||||
{
|
||||
"id": "wh_a1b2c3d4e5f6",
|
||||
"url": "https://myapp.com/api/webhooks/bootnode",
|
||||
"chain": "ethereum",
|
||||
"event_type": "address_activity",
|
||||
"status": "active",
|
||||
"created_at": "2026-01-15T10:30:00Z"
|
||||
}
|
||||
],
|
||||
"page": 1,
|
||||
"total": 1
|
||||
}`}
|
||||
/>
|
||||
<Endpoint
|
||||
method="PATCH"
|
||||
path="/v1/webhooks/{id}"
|
||||
description="Update a webhook subscription. You can change the URL, status, or filter configuration."
|
||||
params={[
|
||||
{ name: "id", type: "string", description: "Webhook ID (e.g. wh_a1b2c3d4e5f6)" },
|
||||
{ name: "url", type: "string", description: "New webhook URL", required: false },
|
||||
{ name: "status", type: "string", description: "Set status: active, paused", required: false },
|
||||
{ name: "config", type: "object", description: "Updated filter configuration", required: false },
|
||||
]}
|
||||
request={`curl -X PATCH https://api.bootnode.dev/v1/webhooks/wh_a1b2c3d4e5f6 \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "X-API-Key: YOUR_API_KEY" \\
|
||||
-d '{
|
||||
"status": "paused"
|
||||
}'`}
|
||||
response={`{
|
||||
"id": "wh_a1b2c3d4e5f6",
|
||||
"url": "https://myapp.com/api/webhooks/bootnode",
|
||||
"chain": "ethereum",
|
||||
"event_type": "address_activity",
|
||||
"status": "paused",
|
||||
"updated_at": "2026-01-15T12:00:00Z"
|
||||
}`}
|
||||
/>
|
||||
<Endpoint
|
||||
method="DELETE"
|
||||
path="/v1/webhooks/{id}"
|
||||
description="Permanently delete a webhook subscription. This action cannot be undone."
|
||||
params={[
|
||||
{ name: "id", type: "string", description: "Webhook ID" },
|
||||
]}
|
||||
request={`curl -X DELETE https://api.bootnode.dev/v1/webhooks/wh_a1b2c3d4e5f6 \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`}
|
||||
response={`{
|
||||
"id": "wh_a1b2c3d4e5f6",
|
||||
"deleted": true
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Gas */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold">Gas</h2>
|
||||
<div className="space-y-6">
|
||||
<Endpoint
|
||||
method="GET"
|
||||
path="/v1/gas/{chain}/prices"
|
||||
description="Get current gas prices for a chain, including slow, standard, and fast estimates."
|
||||
params={[
|
||||
{ name: "chain", type: "string", description: "Chain identifier (ethereum, polygon, base, etc.)" },
|
||||
]}
|
||||
request={`curl https://api.bootnode.dev/v1/gas/ethereum/prices \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`}
|
||||
response={`{
|
||||
"chain": "ethereum",
|
||||
"block_number": 20574335,
|
||||
"base_fee_gwei": "12.50",
|
||||
"estimates": {
|
||||
"slow": {
|
||||
"max_fee_gwei": "14.00",
|
||||
"max_priority_fee_gwei": "0.50",
|
||||
"estimated_seconds": 60
|
||||
},
|
||||
"standard": {
|
||||
"max_fee_gwei": "16.00",
|
||||
"max_priority_fee_gwei": "1.50",
|
||||
"estimated_seconds": 15
|
||||
},
|
||||
"fast": {
|
||||
"max_fee_gwei": "20.00",
|
||||
"max_priority_fee_gwei": "3.00",
|
||||
"estimated_seconds": 5
|
||||
}
|
||||
},
|
||||
"updated_at": "2026-01-15T10:30:00Z"
|
||||
}`}
|
||||
/>
|
||||
<Endpoint
|
||||
method="POST"
|
||||
path="/v1/gas/policies"
|
||||
description="Create a gas sponsorship policy. Policies define rules for when Bootnode will pay gas fees on behalf of your users."
|
||||
params={[
|
||||
{ name: "name", type: "string", description: "Human-readable policy name" },
|
||||
{ name: "chain", type: "string", description: "Chain this policy applies to" },
|
||||
{ name: "rules", type: "object", description: "Sponsorship rules (max_gas_usd, allowed_contracts, rate_limit, etc.)" },
|
||||
]}
|
||||
request={`curl -X POST https://api.bootnode.dev/v1/gas/policies \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "X-API-Key: YOUR_API_KEY" \\
|
||||
-d '{
|
||||
"name": "My App Free Gas",
|
||||
"chain": "base",
|
||||
"rules": {
|
||||
"max_gas_usd": "0.50",
|
||||
"max_per_user_per_day": 10,
|
||||
"allowed_contracts": [
|
||||
"0x1234567890abcdef1234567890abcdef12345678"
|
||||
],
|
||||
"allowed_methods": ["transfer", "approve", "mint"]
|
||||
}
|
||||
}'`}
|
||||
response={`{
|
||||
"id": "gp_x1y2z3w4v5u6",
|
||||
"name": "My App Free Gas",
|
||||
"chain": "base",
|
||||
"rules": {
|
||||
"max_gas_usd": "0.50",
|
||||
"max_per_user_per_day": 10,
|
||||
"allowed_contracts": [
|
||||
"0x1234567890abcdef1234567890abcdef12345678"
|
||||
],
|
||||
"allowed_methods": ["transfer", "approve", "mint"]
|
||||
},
|
||||
"status": "active",
|
||||
"created_at": "2026-01-15T10:30:00Z"
|
||||
}`}
|
||||
/>
|
||||
<Endpoint
|
||||
method="POST"
|
||||
path="/v1/gas/sponsor"
|
||||
description="Sponsor a specific UserOperation. Returns a signed paymaster data field to include in the UserOperation."
|
||||
params={[
|
||||
{ name: "chain", type: "string", description: "Chain identifier" },
|
||||
{ name: "policy_id", type: "string", description: "Gas policy ID" },
|
||||
{ name: "user_operation", type: "object", description: "The UserOperation to sponsor" },
|
||||
]}
|
||||
request={`curl -X POST https://api.bootnode.dev/v1/gas/sponsor \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "X-API-Key: YOUR_API_KEY" \\
|
||||
-d '{
|
||||
"chain": "base",
|
||||
"policy_id": "gp_x1y2z3w4v5u6",
|
||||
"user_operation": {
|
||||
"sender": "0x7A0b3e4C5F1234567890abcdef1234567890ABCD",
|
||||
"nonce": "0x0",
|
||||
"callData": "0xb61d27f6...",
|
||||
"callGasLimit": "0x5208",
|
||||
"verificationGasLimit": "0x186a0",
|
||||
"preVerificationGas": "0xc350",
|
||||
"maxFeePerGas": "0x3b9aca00",
|
||||
"maxPriorityFeePerGas": "0x59682f00"
|
||||
}
|
||||
}'`}
|
||||
response={`{
|
||||
"paymaster": "0xBootnodePaymasterAddress",
|
||||
"paymasterData": "0x0000000067b2c3d4...signed_data",
|
||||
"paymasterVerificationGasLimit": "0x30000",
|
||||
"paymasterPostOpGasLimit": "0x10000",
|
||||
"sponsored_gas_usd": "0.03"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Bundler */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold">Bundler</h2>
|
||||
<Endpoint
|
||||
method="POST"
|
||||
path="/v1/bundler/{chain}/{network}"
|
||||
description="ERC-4337 bundler JSON-RPC endpoint. Supports eth_sendUserOperation, eth_estimateUserOperationGas, eth_getUserOperationByHash, eth_getUserOperationReceipt, and eth_supportedEntryPoints."
|
||||
params={[
|
||||
{ name: "chain", type: "string", description: "Chain identifier" },
|
||||
{ name: "network", type: "string", description: "Network name (mainnet, sepolia, etc.)" },
|
||||
]}
|
||||
request={`curl -X POST https://api.bootnode.dev/v1/bundler/ethereum/mainnet \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "X-API-Key: YOUR_API_KEY" \\
|
||||
-d '{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "eth_sendUserOperation",
|
||||
"params": [
|
||||
{
|
||||
"sender": "0x7A0b3e4C5F1234567890abcdef1234567890ABCD",
|
||||
"nonce": "0x0",
|
||||
"initCode": "0x",
|
||||
"callData": "0xb61d27f6...",
|
||||
"callGasLimit": "0x5208",
|
||||
"verificationGasLimit": "0x186a0",
|
||||
"preVerificationGas": "0xc350",
|
||||
"maxFeePerGas": "0x3b9aca00",
|
||||
"maxPriorityFeePerGas": "0x59682f00",
|
||||
"paymasterAndData": "0x",
|
||||
"signature": "0xabcdef..."
|
||||
},
|
||||
"0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789"
|
||||
]
|
||||
}'`}
|
||||
response={`{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
|
||||
}`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* WebSocket */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold">WebSocket</h2>
|
||||
<Endpoint
|
||||
method="WS"
|
||||
path="/v1/ws/{chain}/{network}"
|
||||
description="WebSocket endpoint for real-time blockchain subscriptions. Supports eth_subscribe for newHeads, logs, newPendingTransactions, and syncing."
|
||||
params={[
|
||||
{ name: "chain", type: "string", description: "Chain identifier" },
|
||||
{ name: "network", type: "string", description: "Network name (mainnet, sepolia, etc.)" },
|
||||
]}
|
||||
request={`wscat -c "wss://api.bootnode.dev/v1/ws/ethereum/mainnet?apiKey=YOUR_API_KEY"
|
||||
|
||||
> {"jsonrpc":"2.0","id":1,"method":"eth_subscribe","params":["newHeads"]}`}
|
||||
response={`{"jsonrpc":"2.0","id":1,"result":"0x1a2b3c4d"}
|
||||
|
||||
// Subsequent messages:
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "eth_subscription",
|
||||
"params": {
|
||||
"subscription": "0x1a2b3c4d",
|
||||
"result": {
|
||||
"number": "0x13a5e80",
|
||||
"hash": "0xabc123...",
|
||||
"parentHash": "0xdef456...",
|
||||
"timestamp": "0x67890abc",
|
||||
"gasUsed": "0xe4e1c0",
|
||||
"gasLimit": "0x1c9c380",
|
||||
"baseFeePerGas": "0x2e90edd00"
|
||||
}
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Error Codes */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold">Error Codes</h2>
|
||||
<div className="overflow-x-auto border rounded-lg">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="text-left p-3 font-medium">HTTP Status</th>
|
||||
<th className="text-left p-3 font-medium">Error Code</th>
|
||||
<th className="text-left p-3 font-medium">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b">
|
||||
<td className="p-3 font-mono">400</td>
|
||||
<td className="p-3 font-mono">invalid_request</td>
|
||||
<td className="p-3 text-muted-foreground">Malformed request body or missing required parameters</td>
|
||||
</tr>
|
||||
<tr className="border-b">
|
||||
<td className="p-3 font-mono">401</td>
|
||||
<td className="p-3 font-mono">unauthorized</td>
|
||||
<td className="p-3 text-muted-foreground">Missing or invalid API key</td>
|
||||
</tr>
|
||||
<tr className="border-b">
|
||||
<td className="p-3 font-mono">403</td>
|
||||
<td className="p-3 font-mono">forbidden</td>
|
||||
<td className="p-3 text-muted-foreground">API key does not have access to this resource</td>
|
||||
</tr>
|
||||
<tr className="border-b">
|
||||
<td className="p-3 font-mono">404</td>
|
||||
<td className="p-3 font-mono">not_found</td>
|
||||
<td className="p-3 text-muted-foreground">Resource does not exist</td>
|
||||
</tr>
|
||||
<tr className="border-b">
|
||||
<td className="p-3 font-mono">429</td>
|
||||
<td className="p-3 font-mono">rate_limited</td>
|
||||
<td className="p-3 text-muted-foreground">Rate limit exceeded. Check X-RateLimit headers.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="p-3 font-mono">500</td>
|
||||
<td className="p-3 font-mono">internal_error</td>
|
||||
<td className="p-3 text-muted-foreground">Internal server error. Retry with exponential backoff.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">All error responses follow this format:</p>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`{
|
||||
"error": {
|
||||
"code": "rate_limited",
|
||||
"message": "Rate limit exceeded. Retry after 1 second.",
|
||||
"status": 429
|
||||
}
|
||||
}`}</code>
|
||||
</pre>
|
||||
</section>
|
||||
</div>
|
||||
</DocsLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
import Link from "next/link"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { DocsLayout } from "@/components/docs-layout"
|
||||
import { ArrowRight } from "lucide-react"
|
||||
|
||||
export const metadata = {
|
||||
title: "Authentication",
|
||||
description: "Learn how to authenticate with the Bootnode API.",
|
||||
}
|
||||
|
||||
export default function AuthQuickstartPage() {
|
||||
return (
|
||||
<DocsLayout>
|
||||
<div className="space-y-10">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold mb-4">Authentication</h1>
|
||||
<p className="text-lg text-muted-foreground">
|
||||
Every request to the Bootnode API must be authenticated. This guide covers
|
||||
how to get your API key, use it in requests, handle rate limits, and manage
|
||||
errors.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Step 1 */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge className="rounded-full h-8 w-8 flex items-center justify-center text-sm">1</Badge>
|
||||
<h2 className="text-2xl font-semibold">Get Your API Key</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Sign in to your Bootnode dashboard at{" "}
|
||||
<Link href="https://dashboard.bootnode.dev" className="text-primary underline underline-offset-4">
|
||||
dashboard.bootnode.dev
|
||||
</Link>{" "}
|
||||
and navigate to <strong>Settings → API Keys</strong>.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div className="border rounded-lg p-4">
|
||||
<p className="font-medium text-sm mb-2">Key types</p>
|
||||
<div className="overflow-x-auto border rounded-lg">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="text-left p-3 font-medium">Prefix</th>
|
||||
<th className="text-left p-3 font-medium">Environment</th>
|
||||
<th className="text-left p-3 font-medium">Use for</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b">
|
||||
<td className="p-3 font-mono text-xs">bn_live_</td>
|
||||
<td className="p-3">Production</td>
|
||||
<td className="p-3 text-muted-foreground">Live mainnet requests</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="p-3 font-mono text-xs">bn_test_</td>
|
||||
<td className="p-3">Testnet</td>
|
||||
<td className="p-3 text-muted-foreground">Development and testing (testnets only)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm font-medium">Security</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Never expose your API key in client-side code, public repositories, or browser
|
||||
network requests. Always use server-side code or environment variables.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`# .env.local (Next.js, Vite, etc.)
|
||||
BOOTNODE_API_KEY=bn_live_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
|
||||
|
||||
# Never commit this file to version control.
|
||||
# Add .env.local to your .gitignore.`}</code>
|
||||
</pre>
|
||||
</section>
|
||||
|
||||
{/* Step 2 */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge className="rounded-full h-8 w-8 flex items-center justify-center text-sm">2</Badge>
|
||||
<h2 className="text-2xl font-semibold">Use the X-API-Key Header</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
The recommended authentication method. Pass your API key in the{" "}
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded text-sm">X-API-Key</code> header.
|
||||
</p>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`# curl
|
||||
curl https://api.bootnode.dev/v1/gas/ethereum/prices \\
|
||||
-H "X-API-Key: bn_live_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6"
|
||||
|
||||
# TypeScript / fetch
|
||||
const res = await fetch("https://api.bootnode.dev/v1/gas/ethereum/prices", {
|
||||
headers: {
|
||||
"X-API-Key": process.env.BOOTNODE_API_KEY!,
|
||||
},
|
||||
});
|
||||
|
||||
# Python / requests
|
||||
import requests
|
||||
res = requests.get(
|
||||
"https://api.bootnode.dev/v1/gas/ethereum/prices",
|
||||
headers={"X-API-Key": "bn_live_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6"},
|
||||
)
|
||||
|
||||
# Go / net/http
|
||||
req, _ := http.NewRequest("GET", "https://api.bootnode.dev/v1/gas/ethereum/prices", nil)
|
||||
req.Header.Set("X-API-Key", "bn_live_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6")`}</code>
|
||||
</pre>
|
||||
</section>
|
||||
|
||||
{/* Step 3 */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge className="rounded-full h-8 w-8 flex items-center justify-center text-sm">3</Badge>
|
||||
<h2 className="text-2xl font-semibold">Use the Authorization Bearer Header</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Alternatively, use the standard Bearer token format. Both methods are equivalent.
|
||||
</p>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`# curl
|
||||
curl https://api.bootnode.dev/v1/gas/ethereum/prices \\
|
||||
-H "Authorization: Bearer bn_live_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6"
|
||||
|
||||
# TypeScript / fetch
|
||||
const res = await fetch("https://api.bootnode.dev/v1/gas/ethereum/prices", {
|
||||
headers: {
|
||||
Authorization: \`Bearer \${process.env.BOOTNODE_API_KEY}\`,
|
||||
},
|
||||
});
|
||||
|
||||
# Python / requests
|
||||
import requests
|
||||
res = requests.get(
|
||||
"https://api.bootnode.dev/v1/gas/ethereum/prices",
|
||||
headers={"Authorization": "Bearer bn_live_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6"},
|
||||
)`}</code>
|
||||
</pre>
|
||||
</section>
|
||||
|
||||
{/* Step 4 */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge className="rounded-full h-8 w-8 flex items-center justify-center text-sm">4</Badge>
|
||||
<h2 className="text-2xl font-semibold">Rate Limiting</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
All API responses include rate limit headers. When you exceed your limit,
|
||||
the API returns HTTP 429.
|
||||
</p>
|
||||
<div className="overflow-x-auto border rounded-lg">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="text-left p-3 font-medium">Plan</th>
|
||||
<th className="text-left p-3 font-medium">Requests / sec</th>
|
||||
<th className="text-left p-3 font-medium">Requests / day</th>
|
||||
<th className="text-left p-3 font-medium">Compute Units / sec</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b">
|
||||
<td className="p-3">Free</td>
|
||||
<td className="p-3">10</td>
|
||||
<td className="p-3">100,000</td>
|
||||
<td className="p-3">200</td>
|
||||
</tr>
|
||||
<tr className="border-b">
|
||||
<td className="p-3">Growth</td>
|
||||
<td className="p-3">100</td>
|
||||
<td className="p-3">5,000,000</td>
|
||||
<td className="p-3">2,000</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="p-3">Enterprise</td>
|
||||
<td className="p-3">Custom</td>
|
||||
<td className="p-3">Custom</td>
|
||||
<td className="p-3">Custom</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Response headers:</p>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`X-RateLimit-Limit: 100 # Max requests per second
|
||||
X-RateLimit-Remaining: 97 # Remaining in current window
|
||||
X-RateLimit-Reset: 1706400000 # Unix timestamp when window resets
|
||||
Retry-After: 1 # Seconds to wait (only on 429)`}</code>
|
||||
</pre>
|
||||
<p className="text-muted-foreground">
|
||||
Different methods consume different compute units (CU). Simple reads like{" "}
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded text-sm">eth_blockNumber</code> cost 1 CU,
|
||||
while archive calls like{" "}
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded text-sm">eth_getStorageAt</code> with a
|
||||
historical block cost 20 CU.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Step 5 */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge className="rounded-full h-8 w-8 flex items-center justify-center text-sm">5</Badge>
|
||||
<h2 className="text-2xl font-semibold">Error Handling</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Bootnode returns structured error responses. Always check the HTTP status code
|
||||
and handle errors gracefully with retries for transient failures.
|
||||
</p>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`// Robust API client with retry logic
|
||||
async function bootnodeRequest(
|
||||
path: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<any> {
|
||||
const maxRetries = 3;
|
||||
const baseDelay = 1000; // 1 second
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
const res = await fetch(\`https://api.bootnode.dev/v1\${path}\`, {
|
||||
...options,
|
||||
headers: {
|
||||
"X-API-Key": process.env.BOOTNODE_API_KEY!,
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
// Success
|
||||
if (res.ok) {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Parse error
|
||||
const error = await res.json().catch(() => ({
|
||||
error: { code: "unknown", message: res.statusText, status: res.status },
|
||||
}));
|
||||
|
||||
// Don't retry client errors (except rate limits)
|
||||
if (res.status !== 429 && res.status < 500) {
|
||||
throw new Error(
|
||||
\`Bootnode API error [\${error.error.code}]: \${error.error.message}\`
|
||||
);
|
||||
}
|
||||
|
||||
// Retry with exponential backoff
|
||||
if (attempt < maxRetries) {
|
||||
const retryAfter = res.headers.get("Retry-After");
|
||||
const delay = retryAfter
|
||||
? parseInt(retryAfter, 10) * 1000
|
||||
: baseDelay * Math.pow(2, attempt);
|
||||
console.warn(
|
||||
\`Bootnode request failed (attempt \${attempt + 1}/\${maxRetries + 1}), \` +
|
||||
\`retrying in \${delay}ms...\`
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Bootnode API: max retries exceeded");
|
||||
}
|
||||
|
||||
// Usage:
|
||||
// const prices = await bootnodeRequest("/gas/ethereum/prices");
|
||||
// const wallet = await bootnodeRequest("/wallets/create", {
|
||||
// method: "POST",
|
||||
// body: JSON.stringify({ owner: "0x...", chain: "base" }),
|
||||
// });`}</code>
|
||||
</pre>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6">Error Response Format</h3>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`// 401 Unauthorized
|
||||
{
|
||||
"error": {
|
||||
"code": "unauthorized",
|
||||
"message": "Invalid API key. Check your X-API-Key header.",
|
||||
"status": 401
|
||||
}
|
||||
}
|
||||
|
||||
// 429 Rate Limited
|
||||
{
|
||||
"error": {
|
||||
"code": "rate_limited",
|
||||
"message": "Rate limit exceeded. Retry after 1 second.",
|
||||
"status": 429
|
||||
}
|
||||
}
|
||||
|
||||
// 400 Bad Request
|
||||
{
|
||||
"error": {
|
||||
"code": "invalid_request",
|
||||
"message": "Missing required parameter: chain",
|
||||
"status": 400
|
||||
}
|
||||
}`}</code>
|
||||
</pre>
|
||||
</section>
|
||||
|
||||
{/* Key Scopes */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold">API Key Scopes</h2>
|
||||
<p className="text-muted-foreground">
|
||||
When creating an API key, you can restrict it to specific scopes for
|
||||
least-privilege access:
|
||||
</p>
|
||||
<div className="overflow-x-auto border rounded-lg">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="text-left p-3 font-medium">Scope</th>
|
||||
<th className="text-left p-3 font-medium">Access</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b">
|
||||
<td className="p-3 font-mono text-xs">rpc:read</td>
|
||||
<td className="p-3 text-muted-foreground">JSON-RPC read methods (eth_call, eth_getBalance, etc.)</td>
|
||||
</tr>
|
||||
<tr className="border-b">
|
||||
<td className="p-3 font-mono text-xs">rpc:write</td>
|
||||
<td className="p-3 text-muted-foreground">JSON-RPC write methods (eth_sendRawTransaction)</td>
|
||||
</tr>
|
||||
<tr className="border-b">
|
||||
<td className="p-3 font-mono text-xs">data:read</td>
|
||||
<td className="p-3 text-muted-foreground">Token, NFT, and transfer APIs</td>
|
||||
</tr>
|
||||
<tr className="border-b">
|
||||
<td className="p-3 font-mono text-xs">wallets:manage</td>
|
||||
<td className="p-3 text-muted-foreground">Create and manage smart wallets</td>
|
||||
</tr>
|
||||
<tr className="border-b">
|
||||
<td className="p-3 font-mono text-xs">webhooks:manage</td>
|
||||
<td className="p-3 text-muted-foreground">Create, update, and delete webhooks</td>
|
||||
</tr>
|
||||
<tr className="border-b">
|
||||
<td className="p-3 font-mono text-xs">gas:manage</td>
|
||||
<td className="p-3 text-muted-foreground">Gas policies and sponsorship</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="p-3 font-mono text-xs">bundler:send</td>
|
||||
<td className="p-3 text-muted-foreground">Submit UserOperations to the bundler</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Next Steps */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold">Next Steps</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{[
|
||||
{ title: "Quickstart", href: "/docs/quickstart", desc: "Make your first API call" },
|
||||
{ title: "API Reference", href: "/docs/api", desc: "Complete endpoint documentation" },
|
||||
{ title: "SDKs", href: "/docs/sdks", desc: "Use a client library" },
|
||||
{ title: "Examples", href: "/docs/examples", desc: "Common use cases" },
|
||||
].map((item) => (
|
||||
<Link
|
||||
key={item.title}
|
||||
href={item.href}
|
||||
className="flex items-center justify-between p-4 border rounded-lg hover:bg-muted transition-colors group"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">{item.title}</p>
|
||||
<p className="text-sm text-muted-foreground">{item.desc}</p>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</DocsLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { DocsLayout } from "@/components/docs-layout"
|
||||
|
||||
export const metadata = {
|
||||
title: "Changelog",
|
||||
description: "Bootnode platform changelog and release notes.",
|
||||
}
|
||||
|
||||
export default function ChangelogPage() {
|
||||
return (
|
||||
<DocsLayout>
|
||||
<div className="space-y-10">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold mb-4">Changelog</h1>
|
||||
<p className="text-lg text-muted-foreground">
|
||||
All notable changes to the Bootnode platform, API, and SDKs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* v2.0.0 */}
|
||||
<section>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<CardTitle className="text-xl">v2.0.0</CardTitle>
|
||||
<Badge>Latest</Badge>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">January 2026</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<p className="text-muted-foreground">
|
||||
General availability of the Bootnode platform. This is a complete rewrite
|
||||
with a unified API, new product lines, and multi-chain support from day one.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-3">Multi-Chain RPC (Node)</h3>
|
||||
<ul className="list-disc list-inside text-muted-foreground space-y-1.5">
|
||||
<li>Support for 100+ blockchain networks with 99.999% uptime SLA</li>
|
||||
<li>Ethereum, Solana, Base, Arbitrum, Polygon, Optimism, Avalanche, BNB Smart Chain, Lux Network, and more</li>
|
||||
<li>Full EVM JSON-RPC support including archive data, debug/trace methods, and eth_subscribe</li>
|
||||
<li>Solana JSON-RPC with getAccountInfo, getTransaction, and all standard methods</li>
|
||||
<li>Intelligent routing across multiple node providers for maximum reliability</li>
|
||||
<li>WebSocket subscriptions for newHeads, logs, and newPendingTransactions</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-3">Smart Wallets</h3>
|
||||
<ul className="list-disc list-inside text-muted-foreground space-y-1.5">
|
||||
<li>ERC-4337 smart wallet creation with deterministic (counterfactual) addresses</li>
|
||||
<li>Automatic on-chain deployment when the first UserOperation is sent</li>
|
||||
<li>Multi-chain support: Ethereum, Base, Arbitrum, Optimism, Polygon, and more</li>
|
||||
<li>Wallet management API: create, list, and query smart wallets</li>
|
||||
<li>Session keys and batch execution support</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-3">Webhooks</h3>
|
||||
<ul className="list-disc list-inside text-muted-foreground space-y-1.5">
|
||||
<li>Real-time on-chain event notifications via HTTPS POST</li>
|
||||
<li>Event types: address_activity, token_transfer, nft_transfer, mined_transaction, log</li>
|
||||
<li>HMAC-SHA256 signature verification for secure delivery</li>
|
||||
<li>Automatic retries with exponential backoff (5 attempts over 6 hours)</li>
|
||||
<li>Webhook management API: create, list, update, pause, and delete</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-3">Gas Manager</h3>
|
||||
<ul className="list-disc list-inside text-muted-foreground space-y-1.5">
|
||||
<li>Gas sponsorship policies with per-user rate limits and contract allowlists</li>
|
||||
<li>Paymaster integration for ERC-4337 UserOperations</li>
|
||||
<li>Real-time gas price estimates (slow, standard, fast) for all EVM chains</li>
|
||||
<li>Spending controls: max gas per operation, daily per-user limits, method-level allowlists</li>
|
||||
<li>Dashboard analytics for gas spending and sponsorship usage</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-3">Bundler</h3>
|
||||
<ul className="list-disc list-inside text-muted-foreground space-y-1.5">
|
||||
<li>ERC-4337 bundler for submitting and managing UserOperations</li>
|
||||
<li>Supports eth_sendUserOperation, eth_estimateUserOperationGas, eth_getUserOperationByHash, and eth_getUserOperationReceipt</li>
|
||||
<li>EntryPoint v0.6 and v0.7 support</li>
|
||||
<li>Available on all EVM chains with ERC-4337 support</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-3">Data APIs</h3>
|
||||
<ul className="list-disc list-inside text-muted-foreground space-y-1.5">
|
||||
<li>Token API: ERC-20 balances, metadata, prices, and transfer history</li>
|
||||
<li>NFT API: ERC-721/ERC-1155 metadata, collection data, owned NFTs, and traits</li>
|
||||
<li>Cross-chain data access with a unified API format</li>
|
||||
<li>Real-time indexing with sub-second latency on supported chains</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-3">SDKs</h3>
|
||||
<ul className="list-disc list-inside text-muted-foreground space-y-1.5">
|
||||
<li>TypeScript/JavaScript SDK v2.0 with full API coverage and WebSocket support</li>
|
||||
<li>Python SDK v2.0 with async-first design and Pydantic models</li>
|
||||
<li>Go SDK v1.0 with context-aware, idiomatic API</li>
|
||||
<li>Rust SDK v0.9 with Tokio async and Serde serialization</li>
|
||||
<li>All SDKs include automatic retry logic and structured error handling</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-3">Platform</h3>
|
||||
<ul className="list-disc list-inside text-muted-foreground space-y-1.5">
|
||||
<li>New dashboard at dashboard.bootnode.dev with usage analytics, API key management, and webhook monitoring</li>
|
||||
<li>Unified authentication via X-API-Key or Bearer token</li>
|
||||
<li>Scoped API keys for least-privilege access</li>
|
||||
<li>Rate limiting with per-plan compute unit budgets</li>
|
||||
<li>llms.txt endpoint for AI agent discovery</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* v1.0.0 */}
|
||||
<section>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<CardTitle className="text-xl">v1.0.0</CardTitle>
|
||||
<span className="text-sm text-muted-foreground">November 2025</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<p className="text-muted-foreground">
|
||||
Beta launch of the Bootnode platform with core RPC proxy and Token API functionality.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-3">RPC Proxy</h3>
|
||||
<ul className="list-disc list-inside text-muted-foreground space-y-1.5">
|
||||
<li>JSON-RPC proxy for Ethereum mainnet and Sepolia testnet</li>
|
||||
<li>Support for all standard Ethereum JSON-RPC methods</li>
|
||||
<li>Basic load balancing across multiple node providers</li>
|
||||
<li>API key authentication</li>
|
||||
<li>Rate limiting at 10 requests/second for free tier</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-3">Token API (Beta)</h3>
|
||||
<ul className="list-disc list-inside text-muted-foreground space-y-1.5">
|
||||
<li>ERC-20 token balance queries for Ethereum mainnet</li>
|
||||
<li>Token metadata including name, symbol, decimals, and logo</li>
|
||||
<li>Basic price data from aggregated feeds</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-3">Platform</h3>
|
||||
<ul className="list-disc list-inside text-muted-foreground space-y-1.5">
|
||||
<li>Dashboard with API key management and basic usage stats</li>
|
||||
<li>TypeScript SDK v1.0 with RPC and Token API support</li>
|
||||
<li>Python SDK v1.0 (beta)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
</DocsLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
import Link from "next/link"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { DocsLayout } from "@/components/docs-layout"
|
||||
import { ArrowRight } from "lucide-react"
|
||||
|
||||
export const metadata = {
|
||||
title: "Examples",
|
||||
description: "Common Bootnode API usage examples with full code snippets.",
|
||||
}
|
||||
|
||||
export default function ExamplesPage() {
|
||||
return (
|
||||
<DocsLayout>
|
||||
<div className="space-y-10">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold mb-4">Examples</h1>
|
||||
<p className="text-lg text-muted-foreground">
|
||||
Copy-paste code snippets for the most common blockchain development tasks.
|
||||
All examples use TypeScript with <code className="bg-muted px-1.5 py-0.5 rounded text-sm">fetch</code> --
|
||||
no SDK required.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Example 1: Get ETH Balance */}
|
||||
<section className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Get ETH Balance</CardTitle>
|
||||
<Badge variant="secondary">RPC</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Fetch the native ETH balance for any address on any EVM chain using
|
||||
the standard <code className="bg-muted px-1.5 py-0.5 rounded text-xs">eth_getBalance</code> RPC method.
|
||||
</p>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`const API_KEY = process.env.BOOTNODE_API_KEY!;
|
||||
|
||||
async function getEthBalance(
|
||||
chain: string,
|
||||
address: string
|
||||
): Promise<{ wei: string; eth: string }> {
|
||||
const res = await fetch(
|
||||
\`https://api.bootnode.dev/v1/rpc/\${chain}/mainnet\`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": API_KEY,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "eth_getBalance",
|
||||
params: [address, "latest"],
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await res.json();
|
||||
const wei = BigInt(data.result);
|
||||
const eth = (Number(wei) / 1e18).toFixed(6);
|
||||
return { wei: wei.toString(), eth };
|
||||
}
|
||||
|
||||
// Usage
|
||||
const balance = await getEthBalance(
|
||||
"ethereum",
|
||||
"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
|
||||
);
|
||||
console.log(\`Balance: \${balance.eth} ETH (\${balance.wei} wei)\`);
|
||||
// => "Balance: 1234.567890 ETH (1234567890000000000000 wei)"`}</code>
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Example 2: Get ERC-20 Token Balances */}
|
||||
<section className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Get ERC-20 Token Balances</CardTitle>
|
||||
<Badge variant="secondary">Token API</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Fetch all ERC-20 token balances for a wallet in a single call using the
|
||||
Token API. Returns token symbols, formatted balances, and USD values.
|
||||
</p>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`async function getTokenBalances(chain: string, address: string) {
|
||||
const res = await fetch(
|
||||
\`https://api.bootnode.dev/v1/tokens/\${chain}/balances/\${address}\`,
|
||||
{
|
||||
headers: { "X-API-Key": process.env.BOOTNODE_API_KEY! },
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) throw new Error(\`HTTP \${res.status}\`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Usage
|
||||
const data = await getTokenBalances(
|
||||
"ethereum",
|
||||
"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
|
||||
);
|
||||
|
||||
for (const token of data.tokens) {
|
||||
console.log(
|
||||
\`\${token.symbol}: \${token.formatted_balance} ($\${token.usd_value})\`
|
||||
);
|
||||
}
|
||||
// => "USDC: 15230.50 ($15230.50)"
|
||||
// => "USDT: 8500.00 ($8500.00)"
|
||||
// => "LINK: 250.75 ($3761.25)"`}</code>
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Example 3: Monitor Wallet Activity with Webhooks */}
|
||||
<section className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Monitor Wallet Activity with Webhooks</CardTitle>
|
||||
<Badge variant="secondary">Webhooks</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Set up a webhook to get notified whenever a specific wallet sends
|
||||
or receives any transaction.
|
||||
</p>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`// 1. Create the webhook subscription
|
||||
async function monitorWallet(address: string) {
|
||||
const res = await fetch("https://api.bootnode.dev/v1/webhooks", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": process.env.BOOTNODE_API_KEY!,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: "https://myapp.com/api/webhooks/wallet-activity",
|
||||
chain: "ethereum",
|
||||
event_type: "address_activity",
|
||||
config: {
|
||||
addresses: [address],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const webhook = await res.json();
|
||||
console.log("Webhook created:", webhook.id);
|
||||
console.log("Save this secret:", webhook.signing_secret);
|
||||
return webhook;
|
||||
}
|
||||
|
||||
// 2. Handle incoming events (Next.js API route)
|
||||
// app/api/webhooks/wallet-activity/route.ts
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import crypto from "crypto";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.text();
|
||||
const signature = req.headers.get("x-bootnode-signature")!;
|
||||
const timestamp = req.headers.get("x-bootnode-timestamp")!;
|
||||
|
||||
// Verify signature
|
||||
const payload = \`\${timestamp}.\${body}\`;
|
||||
const expected = crypto
|
||||
.createHmac("sha256", process.env.WEBHOOK_SECRET!)
|
||||
.update(payload)
|
||||
.digest("hex");
|
||||
|
||||
if (!crypto.timingSafeEqual(
|
||||
Buffer.from(signature),
|
||||
Buffer.from(expected)
|
||||
)) {
|
||||
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
|
||||
}
|
||||
|
||||
const event = JSON.parse(body);
|
||||
console.log(\`Activity: \${event.data.from} -> \${event.data.to}\`);
|
||||
console.log(\`Value: \${event.data.value} wei (\${event.data.asset})\`);
|
||||
|
||||
// Send notification, update database, etc.
|
||||
return NextResponse.json({ received: true });
|
||||
}
|
||||
|
||||
// Usage
|
||||
await monitorWallet("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045");`}</code>
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Example 4: Create a Smart Wallet */}
|
||||
<section className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Create a Smart Wallet</CardTitle>
|
||||
<Badge variant="secondary">Wallets</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Create an ERC-4337 smart wallet for a user. The wallet is counterfactually
|
||||
deployed and ready to receive funds immediately.
|
||||
</p>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`async function createWallet(ownerAddress: string, chain: string) {
|
||||
const res = await fetch("https://api.bootnode.dev/v1/wallets/create", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": process.env.BOOTNODE_API_KEY!,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
owner: ownerAddress,
|
||||
chain,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.error.message);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Usage
|
||||
const wallet = await createWallet(
|
||||
"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
|
||||
"base"
|
||||
);
|
||||
|
||||
console.log("Smart wallet address:", wallet.address);
|
||||
console.log("Status:", wallet.status); // "counterfactual"
|
||||
console.log("Chain:", wallet.chain); // "base"
|
||||
|
||||
// The address is deterministic -- same inputs always produce the same address.
|
||||
// Users can send funds to this address before the wallet is deployed on-chain.`}</code>
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Example 5: Send Gasless Transaction */}
|
||||
<section className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Send a Gasless Transaction</CardTitle>
|
||||
<Badge variant="secondary">Bundler + Gas</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Send a sponsored (gasless) ERC-20 transfer via the bundler. The user
|
||||
pays zero gas -- your gas policy covers the cost.
|
||||
</p>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`import { encodeFunctionData, parseAbi } from "viem";
|
||||
|
||||
const BASE_URL = "https://api.bootnode.dev/v1";
|
||||
const API_KEY = process.env.BOOTNODE_API_KEY!;
|
||||
const ENTRY_POINT = "0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789";
|
||||
|
||||
async function sendGaslessTransfer(params: {
|
||||
sender: string;
|
||||
tokenContract: string;
|
||||
to: string;
|
||||
amount: bigint;
|
||||
chain: string;
|
||||
policyId: string;
|
||||
}) {
|
||||
// 1. Encode ERC-20 transfer
|
||||
const callData = encodeFunctionData({
|
||||
abi: parseAbi([
|
||||
"function transfer(address to, uint256 amount) returns (bool)",
|
||||
]),
|
||||
functionName: "transfer",
|
||||
args: [params.to as \`0x\${string}\`, params.amount],
|
||||
});
|
||||
|
||||
// 2. Build UserOperation
|
||||
const userOp = {
|
||||
sender: params.sender,
|
||||
nonce: "0x0",
|
||||
callData,
|
||||
callGasLimit: "0x30000",
|
||||
verificationGasLimit: "0x50000",
|
||||
preVerificationGas: "0xc350",
|
||||
maxFeePerGas: "0x59682f00",
|
||||
maxPriorityFeePerGas: "0x3b9aca00",
|
||||
};
|
||||
|
||||
// 3. Get paymaster sponsorship
|
||||
const sponsorRes = await fetch(\`\${BASE_URL}/gas/sponsor\`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": API_KEY,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
chain: params.chain,
|
||||
policy_id: params.policyId,
|
||||
user_operation: userOp,
|
||||
}),
|
||||
});
|
||||
const sponsor = await sponsorRes.json();
|
||||
|
||||
// 4. Submit to bundler
|
||||
const bundlerRes = await fetch(
|
||||
\`\${BASE_URL}/bundler/\${params.chain}/mainnet\`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": API_KEY,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "eth_sendUserOperation",
|
||||
params: [
|
||||
{
|
||||
...userOp,
|
||||
paymasterAndData: sponsor.paymasterData,
|
||||
signature: "0x", // Sign with owner key in production
|
||||
},
|
||||
ENTRY_POINT,
|
||||
],
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const result = await bundlerRes.json();
|
||||
return result.result; // UserOp hash
|
||||
}
|
||||
|
||||
// Usage
|
||||
const opHash = await sendGaslessTransfer({
|
||||
sender: "0x7A0b3e4C5F1234567890abcdef1234567890ABCD",
|
||||
tokenContract: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", // USDC on Base
|
||||
to: "0x9876543210abcdef9876543210abcdef98765432",
|
||||
amount: 10000000n, // 10 USDC (6 decimals)
|
||||
chain: "base",
|
||||
policyId: "gp_x1y2z3w4v5u6",
|
||||
});
|
||||
console.log("UserOp hash:", opHash);`}</code>
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Example 6: Subscribe to New Blocks */}
|
||||
<section className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Subscribe to New Blocks via WebSocket</CardTitle>
|
||||
<Badge variant="secondary">WebSocket</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Open a WebSocket connection and subscribe to new block headers in real-time.
|
||||
Each message includes block number, hash, timestamp, gas used, and base fee.
|
||||
</p>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`const API_KEY = process.env.BOOTNODE_API_KEY!;
|
||||
|
||||
const ws = new WebSocket(
|
||||
\`wss://api.bootnode.dev/v1/ws/ethereum/mainnet?apiKey=\${API_KEY}\`
|
||||
);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log("Connected to Bootnode WebSocket");
|
||||
|
||||
// Subscribe to new block headers
|
||||
ws.send(JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "eth_subscribe",
|
||||
params: ["newHeads"],
|
||||
}));
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
|
||||
// Subscription confirmation
|
||||
if (msg.id === 1) {
|
||||
console.log("Subscribed! ID:", msg.result);
|
||||
return;
|
||||
}
|
||||
|
||||
// New block
|
||||
if (msg.method === "eth_subscription") {
|
||||
const block = msg.params.result;
|
||||
const blockNum = parseInt(block.number, 16);
|
||||
const gasUsed = parseInt(block.gasUsed, 16);
|
||||
const gasLimit = parseInt(block.gasLimit, 16);
|
||||
const utilization = ((gasUsed / gasLimit) * 100).toFixed(1);
|
||||
const baseFee = (parseInt(block.baseFeePerGas, 16) / 1e9).toFixed(2);
|
||||
|
||||
console.log(
|
||||
\`Block \${blockNum} | \` +
|
||||
\`Gas: \${utilization}% | \` +
|
||||
\`Base fee: \${baseFee} gwei | \` +
|
||||
\`Hash: \${block.hash.slice(0, 10)}...\`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => console.log("Disconnected");
|
||||
ws.onerror = (err) => console.error("Error:", err);
|
||||
|
||||
// Example output:
|
||||
// Block 20574335 | Gas: 50.0% | Base fee: 12.50 gwei | Hash: 0xabc123...
|
||||
// Block 20574336 | Gas: 67.3% | Base fee: 13.12 gwei | Hash: 0xdef456...
|
||||
// Block 20574337 | Gas: 45.8% | Base fee: 12.85 gwei | Hash: 0x789abc...`}</code>
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Example 7: Get NFT Collection Data */}
|
||||
<section className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Get NFT Collection Data</CardTitle>
|
||||
<Badge variant="secondary">NFT API</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Fetch collection-level metadata and a specific NFT token in a collection.
|
||||
</p>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`const API_KEY = process.env.BOOTNODE_API_KEY!;
|
||||
const BASE = "https://api.bootnode.dev/v1";
|
||||
|
||||
// Get collection info
|
||||
async function getCollection(chain: string, contract: string) {
|
||||
const res = await fetch(
|
||||
\`\${BASE}/nfts/\${chain}/collection/\${contract}\`,
|
||||
{ headers: { "X-API-Key": API_KEY } }
|
||||
);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Get specific NFT
|
||||
async function getNFT(chain: string, contract: string, tokenId: string) {
|
||||
const res = await fetch(
|
||||
\`\${BASE}/nfts/\${chain}/metadata/\${contract}/\${tokenId}\`,
|
||||
{ headers: { "X-API-Key": API_KEY } }
|
||||
);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Usage
|
||||
const collection = await getCollection(
|
||||
"ethereum",
|
||||
"0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D"
|
||||
);
|
||||
console.log(\`\${collection.name}: \${collection.total_supply} NFTs\`);
|
||||
console.log(\`Floor: \${collection.floor_price_eth} ETH\`);
|
||||
// => "Bored Ape Yacht Club: 10000 NFTs"
|
||||
// => "Floor: 12.50 ETH"
|
||||
|
||||
const nft = await getNFT(
|
||||
"ethereum",
|
||||
"0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D",
|
||||
"1234"
|
||||
);
|
||||
console.log(\`\${nft.name} - Owner: \${nft.owner}\`);
|
||||
for (const trait of nft.traits) {
|
||||
console.log(\` \${trait.trait_type}: \${trait.value}\`);
|
||||
}
|
||||
// => "Bored Ape #1234 - Owner: 0xd8dA...6045"
|
||||
// => " Background: Aquamarine"
|
||||
// => " Fur: Dark Brown"`}</code>
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Example 8: Get Gas Prices */}
|
||||
<section className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Get Real-Time Gas Prices</CardTitle>
|
||||
<Badge variant="secondary">Gas API</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Fetch current gas price estimates for any chain. Returns slow, standard,
|
||||
and fast options with estimated confirmation times.
|
||||
</p>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`async function getGasPrices(chain: string) {
|
||||
const res = await fetch(
|
||||
\`https://api.bootnode.dev/v1/gas/\${chain}/prices\`,
|
||||
{ headers: { "X-API-Key": process.env.BOOTNODE_API_KEY! } }
|
||||
);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Usage
|
||||
const gas = await getGasPrices("ethereum");
|
||||
|
||||
console.log(\`Base fee: \${gas.base_fee_gwei} gwei\`);
|
||||
console.log(\`Block: \${gas.block_number}\`);
|
||||
console.log("");
|
||||
console.log("Speed | Max Fee | Priority | ETA");
|
||||
console.log("----------|------------|----------|--------");
|
||||
console.log(
|
||||
\`Slow | \${gas.estimates.slow.max_fee_gwei.padStart(8)} | \` +
|
||||
\`\${gas.estimates.slow.max_priority_fee_gwei.padStart(8)} | \` +
|
||||
\`\${gas.estimates.slow.estimated_seconds}s\`
|
||||
);
|
||||
console.log(
|
||||
\`Standard | \${gas.estimates.standard.max_fee_gwei.padStart(8)} | \` +
|
||||
\`\${gas.estimates.standard.max_priority_fee_gwei.padStart(8)} | \` +
|
||||
\`\${gas.estimates.standard.estimated_seconds}s\`
|
||||
);
|
||||
console.log(
|
||||
\`Fast | \${gas.estimates.fast.max_fee_gwei.padStart(8)} | \` +
|
||||
\`\${gas.estimates.fast.max_priority_fee_gwei.padStart(8)} | \` +
|
||||
\`\${gas.estimates.fast.estimated_seconds}s\`
|
||||
);
|
||||
|
||||
// Output:
|
||||
// Base fee: 12.50 gwei
|
||||
// Block: 20574335
|
||||
//
|
||||
// Speed | Max Fee | Priority | ETA
|
||||
// ----------|------------|----------|--------
|
||||
// Slow | 14.00 | 0.50 | 60s
|
||||
// Standard | 16.00 | 1.50 | 15s
|
||||
// Fast | 20.00 | 3.00 | 5s`}</code>
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Next Steps */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold">More Resources</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{[
|
||||
{ title: "API Reference", href: "/docs/api", desc: "Complete endpoint documentation" },
|
||||
{ title: "SDKs", href: "/docs/sdks", desc: "Use a client library" },
|
||||
{ title: "Quickstart", href: "/docs/quickstart", desc: "Step-by-step setup guide" },
|
||||
{ title: "Changelog", href: "/docs/changelog", desc: "Latest features and fixes" },
|
||||
].map((item) => (
|
||||
<Link
|
||||
key={item.title}
|
||||
href={item.href}
|
||||
className="flex items-center justify-between p-4 border rounded-lg hover:bg-muted transition-colors group"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">{item.title}</p>
|
||||
<p className="text-sm text-muted-foreground">{item.desc}</p>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</DocsLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import {
|
||||
Book,
|
||||
Zap,
|
||||
Globe,
|
||||
Webhook,
|
||||
Wallet,
|
||||
Fuel,
|
||||
Code,
|
||||
Terminal,
|
||||
FileCode,
|
||||
ArrowRight,
|
||||
} from "lucide-react"
|
||||
|
||||
const sections = [
|
||||
{
|
||||
title: "Getting Started",
|
||||
description: "Quick start guides and tutorials",
|
||||
icon: Zap,
|
||||
items: [
|
||||
{ name: "Introduction", href: "/docs/intro" },
|
||||
{ name: "Authentication", href: "/docs/auth" },
|
||||
{ name: "Making Requests", href: "/docs/requests" },
|
||||
{ name: "Error Handling", href: "/docs/errors" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Node API",
|
||||
description: "JSON-RPC and REST endpoints for all chains",
|
||||
icon: Globe,
|
||||
items: [
|
||||
{ name: "Overview", href: "/docs/node" },
|
||||
{ name: "Ethereum", href: "/docs/node/ethereum" },
|
||||
{ name: "Solana", href: "/docs/node/solana" },
|
||||
{ name: "All Chains", href: "/docs/node/chains" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Data API",
|
||||
description: "Indexed blockchain data for analytics",
|
||||
icon: FileCode,
|
||||
items: [
|
||||
{ name: "Blocks", href: "/docs/data/blocks" },
|
||||
{ name: "Transactions", href: "/docs/data/transactions" },
|
||||
{ name: "Tokens", href: "/docs/data/tokens" },
|
||||
{ name: "NFTs", href: "/docs/data/nfts" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Webhooks",
|
||||
description: "Real-time event notifications",
|
||||
icon: Webhook,
|
||||
items: [
|
||||
{ name: "Setup", href: "/docs/webhooks/setup" },
|
||||
{ name: "Events", href: "/docs/webhooks/events" },
|
||||
{ name: "Signatures", href: "/docs/webhooks/signatures" },
|
||||
{ name: "Retries", href: "/docs/webhooks/retries" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Smart Wallets",
|
||||
description: "ERC-4337 account abstraction",
|
||||
icon: Wallet,
|
||||
items: [
|
||||
{ name: "Overview", href: "/docs/wallets" },
|
||||
{ name: "Creating Wallets", href: "/docs/wallets/create" },
|
||||
{ name: "User Operations", href: "/docs/wallets/userops" },
|
||||
{ name: "Session Keys", href: "/docs/wallets/sessions" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Gas Manager",
|
||||
description: "Gas sponsorship and paymasters",
|
||||
icon: Fuel,
|
||||
items: [
|
||||
{ name: "Policies", href: "/docs/gas/policies" },
|
||||
{ name: "Paymasters", href: "/docs/gas/paymasters" },
|
||||
{ name: "Sponsorship", href: "/docs/gas/sponsorship" },
|
||||
{ name: "Limits", href: "/docs/gas/limits" },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const sdks = [
|
||||
{ name: "TypeScript", badge: "v2.0", href: "/docs/sdk/typescript" },
|
||||
{ name: "Python", badge: "v2.0", href: "/docs/sdk/python" },
|
||||
{ name: "Go", badge: "v1.0", href: "/docs/sdk/go" },
|
||||
{ name: "Rust", badge: "v0.9", href: "/docs/sdk/rust" },
|
||||
]
|
||||
|
||||
export default function DocsPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Header */}
|
||||
<div className="border-b">
|
||||
<div className="container py-12">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Book className="h-8 w-8 text-primary" />
|
||||
<h1 className="text-4xl font-bold">Documentation</h1>
|
||||
</div>
|
||||
<p className="text-xl text-muted-foreground max-w-2xl">
|
||||
Everything you need to build blockchain applications with Bootnode.
|
||||
Multi-chain RPC, indexed data, webhooks, smart wallets, and more.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="container py-12">
|
||||
{/* Quick Links */}
|
||||
<div className="grid gap-4 md:grid-cols-4 mb-12">
|
||||
<Link
|
||||
href="/docs/quickstart"
|
||||
className="flex items-center justify-between p-4 border rounded-lg hover:bg-muted transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Terminal className="h-5 w-5 text-primary" />
|
||||
<span className="font-medium">Quick Start</span>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
<Link
|
||||
href="/docs/api"
|
||||
className="flex items-center justify-between p-4 border rounded-lg hover:bg-muted transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Code className="h-5 w-5 text-primary" />
|
||||
<span className="font-medium">API Reference</span>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
<Link
|
||||
href="/docs/examples"
|
||||
className="flex items-center justify-between p-4 border rounded-lg hover:bg-muted transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<FileCode className="h-5 w-5 text-primary" />
|
||||
<span className="font-medium">Examples</span>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
<Link
|
||||
href="/docs/changelog"
|
||||
className="flex items-center justify-between p-4 border rounded-lg hover:bg-muted transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Zap className="h-5 w-5 text-primary" />
|
||||
<span className="font-medium">Changelog</span>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* SDKs */}
|
||||
<Card className="mb-12">
|
||||
<CardHeader>
|
||||
<CardTitle>SDKs & Libraries</CardTitle>
|
||||
<CardDescription>
|
||||
Official client libraries for your favorite languages
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
{sdks.map((sdk) => (
|
||||
<Link
|
||||
key={sdk.name}
|
||||
href={sdk.href}
|
||||
className="flex items-center justify-between p-4 border rounded-lg hover:bg-muted transition-colors"
|
||||
>
|
||||
<span className="font-medium">{sdk.name}</span>
|
||||
<Badge variant="secondary">{sdk.badge}</Badge>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Documentation Sections */}
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{sections.map((section) => (
|
||||
<Card key={section.title}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<section.icon className="h-6 w-6 text-primary" />
|
||||
<CardTitle>{section.title}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>{section.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2">
|
||||
{section.items.map((item) => (
|
||||
<li key={item.name}>
|
||||
<Link
|
||||
href={item.href}
|
||||
className="text-sm hover:text-primary transition-colors"
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* API Reference Preview */}
|
||||
<Card className="mt-12">
|
||||
<CardHeader>
|
||||
<CardTitle>API Reference</CardTitle>
|
||||
<CardDescription>
|
||||
Complete reference for all Bootnode APIs
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[
|
||||
{ method: "POST", path: "/v1/rpc/{chain}", desc: "JSON-RPC proxy" },
|
||||
{ method: "GET", path: "/v1/tokens/{chain}", desc: "Token balances" },
|
||||
{ method: "GET", path: "/v1/nfts/{chain}", desc: "NFT collections" },
|
||||
{ method: "GET", path: "/v1/transactions/{chain}", desc: "Transaction history" },
|
||||
{ method: "POST", path: "/v1/webhooks", desc: "Create webhook" },
|
||||
{ method: "POST", path: "/v1/wallets/deploy", desc: "Deploy smart wallet" },
|
||||
].map((endpoint) => (
|
||||
<div
|
||||
key={endpoint.path}
|
||||
className="flex items-center gap-3 p-3 border rounded-lg"
|
||||
>
|
||||
<Badge
|
||||
variant={endpoint.method === "POST" ? "default" : "secondary"}
|
||||
className="font-mono"
|
||||
>
|
||||
{endpoint.method}
|
||||
</Badge>
|
||||
<div>
|
||||
<code className="text-sm">{endpoint.path}</code>
|
||||
<p className="text-xs text-muted-foreground">{endpoint.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import Link from "next/link"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { DocsLayout } from "@/components/docs-layout"
|
||||
import { ArrowRight } from "lucide-react"
|
||||
|
||||
export const metadata = {
|
||||
title: "Quickstart",
|
||||
description: "Get started with Bootnode in under 5 minutes.",
|
||||
}
|
||||
|
||||
export default function QuickstartPage() {
|
||||
return (
|
||||
<DocsLayout>
|
||||
<div className="space-y-10">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold mb-4">Quickstart</h1>
|
||||
<p className="text-lg text-muted-foreground">
|
||||
Get your first blockchain API call running in under 5 minutes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Step 1 */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge className="rounded-full h-8 w-8 flex items-center justify-center text-sm">1</Badge>
|
||||
<h2 className="text-2xl font-semibold">Sign Up and Get Your API Key</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Create a free Bootnode account at{" "}
|
||||
<Link href="https://dashboard.bootnode.dev" className="text-primary underline underline-offset-4">
|
||||
dashboard.bootnode.dev
|
||||
</Link>
|
||||
. After signing up, navigate to <strong>Settings → API Keys</strong> and
|
||||
create a new key. Copy it -- you will need it for every request.
|
||||
</p>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-muted-foreground mb-3">Your API key will look like this:</p>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`bn_live_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6`}</code>
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Step 2 */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge className="rounded-full h-8 w-8 flex items-center justify-center text-sm">2</Badge>
|
||||
<h2 className="text-2xl font-semibold">Make Your First RPC Call</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Use <code className="bg-muted px-1.5 py-0.5 rounded text-sm">curl</code> to
|
||||
call <code className="bg-muted px-1.5 py-0.5 rounded text-sm">eth_blockNumber</code>{" "}
|
||||
on Ethereum mainnet. This returns the latest block number in hexadecimal.
|
||||
</p>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`curl -X POST https://api.bootnode.dev/v1/rpc/ethereum/mainnet \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "X-API-Key: YOUR_API_KEY" \\
|
||||
-d '{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "eth_blockNumber",
|
||||
"params": []
|
||||
}'`}</code>
|
||||
</pre>
|
||||
<p className="text-sm text-muted-foreground">Response:</p>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": "0x13a5e7f"
|
||||
}`}</code>
|
||||
</pre>
|
||||
</section>
|
||||
|
||||
{/* Step 3 */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge className="rounded-full h-8 w-8 flex items-center justify-center text-sm">3</Badge>
|
||||
<h2 className="text-2xl font-semibold">Install the SDK</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
For TypeScript and JavaScript projects, install the official SDK:
|
||||
</p>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`npm install @bootnode/sdk`}</code>
|
||||
</pre>
|
||||
<p className="text-sm text-muted-foreground">Or with other package managers:</p>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`# yarn
|
||||
yarn add @bootnode/sdk
|
||||
|
||||
# pnpm
|
||||
pnpm add @bootnode/sdk
|
||||
|
||||
# bun
|
||||
bun add @bootnode/sdk`}</code>
|
||||
</pre>
|
||||
</section>
|
||||
|
||||
{/* Step 4 */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge className="rounded-full h-8 w-8 flex items-center justify-center text-sm">4</Badge>
|
||||
<h2 className="text-2xl font-semibold">Use the SDK in TypeScript</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Here is a complete TypeScript example that fetches the latest block number,
|
||||
gets an ETH balance, and retrieves ERC-20 token balances for a wallet.
|
||||
</p>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`import { Bootnode } from "@bootnode/sdk";
|
||||
|
||||
const client = new Bootnode({
|
||||
apiKey: process.env.BOOTNODE_API_KEY!,
|
||||
});
|
||||
|
||||
// 1. Get the latest block number via JSON-RPC
|
||||
async function getBlockNumber(): Promise<string> {
|
||||
const response = await client.rpc("ethereum", "mainnet", {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "eth_blockNumber",
|
||||
params: [],
|
||||
});
|
||||
return response.result; // e.g. "0x13a5e7f"
|
||||
}
|
||||
|
||||
// 2. Get ETH balance using JSON-RPC
|
||||
async function getBalance(address: string): Promise<string> {
|
||||
const response = await client.rpc("ethereum", "mainnet", {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "eth_getBalance",
|
||||
params: [address, "latest"],
|
||||
});
|
||||
return response.result; // wei in hex, e.g. "0x6f05b59d3b20000"
|
||||
}
|
||||
|
||||
// 3. Get ERC-20 token balances via Token API
|
||||
async function getTokenBalances(address: string) {
|
||||
const balances = await client.tokens.getBalances("ethereum", address);
|
||||
return balances;
|
||||
// Returns: [{ contract: "0xa0b8...", symbol: "USDC", balance: "1500.00", ... }]
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const blockNumber = await getBlockNumber();
|
||||
console.log("Latest block:", parseInt(blockNumber, 16));
|
||||
|
||||
const address = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045";
|
||||
const balance = await getBalance(address);
|
||||
console.log("ETH balance (wei):", BigInt(balance).toString());
|
||||
|
||||
const tokens = await getTokenBalances(address);
|
||||
console.log("Token balances:", tokens);
|
||||
}
|
||||
|
||||
main();`}</code>
|
||||
</pre>
|
||||
</section>
|
||||
|
||||
{/* Step 5: Using fetch directly */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge className="rounded-full h-8 w-8 flex items-center justify-center text-sm">5</Badge>
|
||||
<h2 className="text-2xl font-semibold">Using Fetch Directly</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
You do not need the SDK. Every Bootnode API is a standard REST or JSON-RPC
|
||||
endpoint. Here is a plain <code className="bg-muted px-1.5 py-0.5 rounded text-sm">fetch</code> example:
|
||||
</p>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`const API_KEY = process.env.BOOTNODE_API_KEY;
|
||||
const BASE_URL = "https://api.bootnode.dev/v1";
|
||||
|
||||
// JSON-RPC call
|
||||
async function ethBlockNumber(): Promise<number> {
|
||||
const res = await fetch(\`\${BASE_URL}/rpc/ethereum/mainnet\`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": API_KEY!,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "eth_blockNumber",
|
||||
params: [],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(\`HTTP \${res.status}: \${await res.text()}\`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
return parseInt(data.result, 16);
|
||||
}
|
||||
|
||||
// REST API call - get token balances
|
||||
async function getTokenBalances(address: string) {
|
||||
const res = await fetch(
|
||||
\`\${BASE_URL}/tokens/ethereum/balances/\${address}\`,
|
||||
{
|
||||
headers: { "X-API-Key": API_KEY! },
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(\`HTTP \${res.status}: \${await res.text()}\`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Usage
|
||||
const blockNum = await ethBlockNumber();
|
||||
console.log("Block:", blockNum);
|
||||
|
||||
const tokens = await getTokenBalances(
|
||||
"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
|
||||
);
|
||||
console.log("Tokens:", tokens);`}</code>
|
||||
</pre>
|
||||
</section>
|
||||
|
||||
{/* Next Steps */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold">Next Steps</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{[
|
||||
{ title: "API Reference", href: "/docs/api", desc: "Full endpoint documentation" },
|
||||
{ title: "Smart Wallets", href: "/docs/wallets/quickstart", desc: "Create ERC-4337 smart wallets" },
|
||||
{ title: "Webhooks", href: "/docs/webhooks/quickstart", desc: "Real-time event notifications" },
|
||||
{ title: "WebSockets", href: "/docs/websockets/quickstart", desc: "Subscribe to live blockchain data" },
|
||||
{ title: "Account Abstraction", href: "/docs/aa/quickstart", desc: "ERC-4337 bundler and paymasters" },
|
||||
{ title: "SDKs", href: "/docs/sdks", desc: "Client libraries for every language" },
|
||||
].map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="flex items-center justify-between p-4 border rounded-lg hover:bg-muted transition-colors group"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">{item.title}</p>
|
||||
<p className="text-sm text-muted-foreground">{item.desc}</p>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</DocsLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
import Link from "next/link"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { DocsLayout } from "@/components/docs-layout"
|
||||
import { ArrowRight } from "lucide-react"
|
||||
|
||||
export const metadata = {
|
||||
title: "SDKs",
|
||||
description: "Official Bootnode client libraries for TypeScript, Python, Go, and Rust.",
|
||||
}
|
||||
|
||||
export default function SDKsPage() {
|
||||
return (
|
||||
<DocsLayout>
|
||||
<div className="space-y-10">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold mb-4">SDKs & Libraries</h1>
|
||||
<p className="text-lg text-muted-foreground">
|
||||
Official client libraries for every major language. Each SDK provides
|
||||
type-safe access to all Bootnode APIs with built-in retry logic,
|
||||
authentication, and error handling.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* TypeScript */}
|
||||
<section className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<CardTitle className="text-xl">TypeScript / JavaScript</CardTitle>
|
||||
</div>
|
||||
<Badge variant="secondary">v2.0.0</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Works with Node.js 18+, Bun, Deno, and all modern browsers (via bundler).
|
||||
Full TypeScript types included.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-2">Install</h4>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`npm install @bootnode/sdk
|
||||
# or
|
||||
yarn add @bootnode/sdk
|
||||
# or
|
||||
pnpm add @bootnode/sdk`}</code>
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-2">Usage</h4>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`import { Bootnode } from "@bootnode/sdk";
|
||||
|
||||
const client = new Bootnode({
|
||||
apiKey: process.env.BOOTNODE_API_KEY!,
|
||||
});
|
||||
|
||||
// JSON-RPC
|
||||
const blockNumber = await client.rpc("ethereum", "mainnet", {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "eth_blockNumber",
|
||||
params: [],
|
||||
});
|
||||
console.log("Block:", parseInt(blockNumber.result, 16));
|
||||
|
||||
// Token API
|
||||
const balances = await client.tokens.getBalances("ethereum", "0xd8dA...6045");
|
||||
console.log("Tokens:", balances.tokens);
|
||||
|
||||
// NFT API
|
||||
const nft = await client.nfts.getMetadata(
|
||||
"ethereum",
|
||||
"0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D",
|
||||
"1234"
|
||||
);
|
||||
console.log("NFT:", nft.name);
|
||||
|
||||
// Wallets
|
||||
const wallet = await client.wallets.create({
|
||||
owner: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
|
||||
chain: "base",
|
||||
});
|
||||
console.log("Smart wallet:", wallet.address);
|
||||
|
||||
// Webhooks
|
||||
const webhook = await client.webhooks.create({
|
||||
url: "https://myapp.com/api/webhooks",
|
||||
chain: "ethereum",
|
||||
eventType: "address_activity",
|
||||
config: { addresses: ["0xd8dA...6045"] },
|
||||
});
|
||||
console.log("Webhook ID:", webhook.id);
|
||||
|
||||
// Gas prices
|
||||
const gas = await client.gas.getPrices("ethereum");
|
||||
console.log("Base fee:", gas.base_fee_gwei, "gwei");
|
||||
|
||||
// WebSocket subscriptions
|
||||
const ws = client.ws("ethereum", "mainnet");
|
||||
ws.subscribe("newHeads", (block) => {
|
||||
console.log("New block:", parseInt(block.number, 16));
|
||||
});
|
||||
|
||||
// Bundler
|
||||
const userOpHash = await client.bundler.sendUserOperation("base", "mainnet", {
|
||||
sender: wallet.address,
|
||||
nonce: "0x0",
|
||||
callData: "0x...",
|
||||
// ... other UserOp fields
|
||||
});
|
||||
console.log("UserOp hash:", userOpHash);`}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Python */}
|
||||
<section className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<CardTitle className="text-xl">Python</CardTitle>
|
||||
</div>
|
||||
<Badge variant="secondary">v2.0.0</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Python 3.10+. Async-first with sync wrappers. Full type hints with Pydantic models.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-2">Install</h4>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`pip install bootnode
|
||||
# or
|
||||
uv add bootnode`}</code>
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-2">Usage</h4>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`import asyncio
|
||||
from bootnode import Bootnode
|
||||
|
||||
client = Bootnode(api_key="bn_live_...")
|
||||
|
||||
async def main():
|
||||
# JSON-RPC
|
||||
result = await client.rpc("ethereum", "mainnet", {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "eth_blockNumber",
|
||||
"params": [],
|
||||
})
|
||||
block = int(result["result"], 16)
|
||||
print(f"Block: {block}")
|
||||
|
||||
# Token balances
|
||||
balances = await client.tokens.get_balances(
|
||||
"ethereum",
|
||||
"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
|
||||
)
|
||||
for token in balances["tokens"]:
|
||||
print(f" {token['symbol']}: {token['formatted_balance']}")
|
||||
|
||||
# NFT metadata
|
||||
nft = await client.nfts.get_metadata(
|
||||
"ethereum",
|
||||
"0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D",
|
||||
"1234"
|
||||
)
|
||||
print(f"NFT: {nft['name']}")
|
||||
|
||||
# Create smart wallet
|
||||
wallet = await client.wallets.create(
|
||||
owner="0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
|
||||
chain="base"
|
||||
)
|
||||
print(f"Wallet: {wallet['address']}")
|
||||
|
||||
# Gas prices
|
||||
gas = await client.gas.get_prices("ethereum")
|
||||
print(f"Base fee: {gas['base_fee_gwei']} gwei")
|
||||
|
||||
asyncio.run(main())
|
||||
|
||||
# Sync usage (for scripts):
|
||||
from bootnode import BootnodeSync
|
||||
|
||||
sync_client = BootnodeSync(api_key="bn_live_...")
|
||||
gas = sync_client.gas.get_prices("ethereum")
|
||||
print(f"Base fee: {gas['base_fee_gwei']} gwei")`}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Go */}
|
||||
<section className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<CardTitle className="text-xl">Go</CardTitle>
|
||||
</div>
|
||||
<Badge variant="secondary">v1.0.0</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Go 1.21+. Context-aware, idiomatic Go with strong types.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-2">Install</h4>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`go get github.com/bootnode-dev/bootnode-go`}</code>
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-2">Usage</h4>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/bootnode-dev/bootnode-go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
client := bootnode.NewClient(os.Getenv("BOOTNODE_API_KEY"))
|
||||
ctx := context.Background()
|
||||
|
||||
// JSON-RPC
|
||||
result, err := client.RPC(ctx, "ethereum", "mainnet", map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "eth_blockNumber",
|
||||
"params": []any{},
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
blockHex := result["result"].(string)
|
||||
block, _ := strconv.ParseInt(blockHex[2:], 16, 64)
|
||||
fmt.Printf("Block: %d\\n", block)
|
||||
|
||||
// Token balances
|
||||
balances, err := client.Tokens.GetBalances(ctx, "ethereum",
|
||||
"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
for _, token := range balances.Tokens {
|
||||
fmt.Printf(" %s: %s\\n", token.Symbol, token.FormattedBalance)
|
||||
}
|
||||
|
||||
// Create smart wallet
|
||||
wallet, err := client.Wallets.Create(ctx, bootnode.CreateWalletParams{
|
||||
Owner: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
|
||||
Chain: "base",
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("Wallet: %s\\n", wallet.Address)
|
||||
|
||||
// Gas prices
|
||||
gas, err := client.Gas.GetPrices(ctx, "ethereum")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("Base fee: %s gwei\\n", gas.BaseFeeGwei)
|
||||
}`}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Rust */}
|
||||
<section className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<CardTitle className="text-xl">Rust</CardTitle>
|
||||
</div>
|
||||
<Badge variant="secondary">v0.9.0</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Rust 1.75+. Async with Tokio. Serde-based serialization. Zero-copy where possible.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-2">Install</h4>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`# Cargo.toml
|
||||
[dependencies]
|
||||
bootnode = "0.9"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde_json = "1"`}</code>
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-2">Usage</h4>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`use bootnode::Client;
|
||||
use serde_json::json;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), bootnode::Error> {
|
||||
let client = Client::new(
|
||||
std::env::var("BOOTNODE_API_KEY")
|
||||
.expect("BOOTNODE_API_KEY must be set")
|
||||
);
|
||||
|
||||
// JSON-RPC
|
||||
let result = client
|
||||
.rpc("ethereum", "mainnet", json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "eth_blockNumber",
|
||||
"params": []
|
||||
}))
|
||||
.await?;
|
||||
|
||||
let block_hex = result["result"].as_str().unwrap();
|
||||
let block = u64::from_str_radix(&block_hex[2..], 16).unwrap();
|
||||
println!("Block: {block}");
|
||||
|
||||
// Token balances
|
||||
let balances = client
|
||||
.tokens()
|
||||
.get_balances("ethereum", "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045")
|
||||
.await?;
|
||||
|
||||
for token in &balances.tokens {
|
||||
println!(" {}: {}", token.symbol, token.formatted_balance);
|
||||
}
|
||||
|
||||
// Create smart wallet
|
||||
let wallet = client
|
||||
.wallets()
|
||||
.create("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "base")
|
||||
.await?;
|
||||
println!("Wallet: {}", wallet.address);
|
||||
|
||||
// Gas prices
|
||||
let gas = client.gas().get_prices("ethereum").await?;
|
||||
println!("Base fee: {} gwei", gas.base_fee_gwei);
|
||||
|
||||
Ok(())
|
||||
}`}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Feature Matrix */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold">Feature Matrix</h2>
|
||||
<div className="overflow-x-auto border rounded-lg">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="text-left p-3 font-medium">Feature</th>
|
||||
<th className="text-left p-3 font-medium">TypeScript</th>
|
||||
<th className="text-left p-3 font-medium">Python</th>
|
||||
<th className="text-left p-3 font-medium">Go</th>
|
||||
<th className="text-left p-3 font-medium">Rust</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[
|
||||
{ feature: "JSON-RPC", ts: true, py: true, go: true, rs: true },
|
||||
{ feature: "Token API", ts: true, py: true, go: true, rs: true },
|
||||
{ feature: "NFT API", ts: true, py: true, go: true, rs: true },
|
||||
{ feature: "Wallets", ts: true, py: true, go: true, rs: true },
|
||||
{ feature: "Webhooks", ts: true, py: true, go: true, rs: true },
|
||||
{ feature: "Gas Manager", ts: true, py: true, go: true, rs: true },
|
||||
{ feature: "Bundler", ts: true, py: true, go: true, rs: true },
|
||||
{ feature: "WebSocket", ts: true, py: true, go: true, rs: true },
|
||||
{ feature: "Auto-retry", ts: true, py: true, go: true, rs: true },
|
||||
{ feature: "Streaming", ts: true, py: true, go: true, rs: true },
|
||||
].map((row) => (
|
||||
<tr key={row.feature} className="border-b last:border-0">
|
||||
<td className="p-3 font-medium">{row.feature}</td>
|
||||
<td className="p-3">{row.ts ? "Yes" : "Coming soon"}</td>
|
||||
<td className="p-3">{row.py ? "Yes" : "Coming soon"}</td>
|
||||
<td className="p-3">{row.go ? "Yes" : "Coming soon"}</td>
|
||||
<td className="p-3">{row.rs ? "Yes" : "Coming soon"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Next Steps */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold">Next Steps</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{[
|
||||
{ title: "Quickstart", href: "/docs/quickstart", desc: "Get your first call running" },
|
||||
{ title: "API Reference", href: "/docs/api", desc: "Full endpoint documentation" },
|
||||
{ title: "Examples", href: "/docs/examples", desc: "Common use cases" },
|
||||
{ title: "Changelog", href: "/docs/changelog", desc: "Latest SDK updates" },
|
||||
].map((item) => (
|
||||
<Link
|
||||
key={item.title}
|
||||
href={item.href}
|
||||
className="flex items-center justify-between p-4 border rounded-lg hover:bg-muted transition-colors group"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">{item.title}</p>
|
||||
<p className="text-sm text-muted-foreground">{item.desc}</p>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</DocsLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
import Link from "next/link"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { DocsLayout } from "@/components/docs-layout"
|
||||
import { ArrowRight } from "lucide-react"
|
||||
|
||||
export const metadata = {
|
||||
title: "Smart Wallets Quickstart",
|
||||
description: "Create and use ERC-4337 smart wallets with Bootnode.",
|
||||
}
|
||||
|
||||
export default function WalletsQuickstartPage() {
|
||||
return (
|
||||
<DocsLayout>
|
||||
<div className="space-y-10">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold mb-4">Smart Wallets Quickstart</h1>
|
||||
<p className="text-lg text-muted-foreground">
|
||||
Create ERC-4337 smart wallets and send gasless transactions in a Next.js app.
|
||||
Smart wallets are counterfactually deployed -- they exist at a deterministic address
|
||||
before any on-chain deployment, and are deployed automatically when the first
|
||||
transaction is sent.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Prerequisites */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-2xl font-semibold">Prerequisites</h2>
|
||||
<ul className="list-disc list-inside text-muted-foreground space-y-1">
|
||||
<li>A Bootnode API key (get one at <Link href="https://dashboard.bootnode.dev" className="text-primary underline underline-offset-4">dashboard.bootnode.dev</Link>)</li>
|
||||
<li>Node.js 18+ installed</li>
|
||||
<li>Basic familiarity with Next.js and TypeScript</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* Step 1 */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge className="rounded-full h-8 w-8 flex items-center justify-center text-sm">1</Badge>
|
||||
<h2 className="text-2xl font-semibold">Create a Smart Wallet</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Call the wallet creation endpoint. You provide an owner address (the EOA that
|
||||
controls this smart wallet), and Bootnode returns the deterministic smart wallet
|
||||
address.
|
||||
</p>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`// lib/bootnode.ts
|
||||
const BOOTNODE_API_KEY = process.env.BOOTNODE_API_KEY!;
|
||||
const BASE_URL = "https://api.bootnode.dev/v1";
|
||||
|
||||
export async function createSmartWallet(ownerAddress: string, chain: string) {
|
||||
const res = await fetch(\`\${BASE_URL}/wallets/create\`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": BOOTNODE_API_KEY,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
owner: ownerAddress,
|
||||
chain: chain,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(\`Failed to create wallet: \${error.error.message}\`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Usage:
|
||||
// const wallet = await createSmartWallet(
|
||||
// "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
|
||||
// "base"
|
||||
// );
|
||||
// console.log(wallet.address);
|
||||
// => "0x7A0b3e4C5F1234567890abcdef1234567890ABCD"`}</code>
|
||||
</pre>
|
||||
</section>
|
||||
|
||||
{/* Step 2 */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge className="rounded-full h-8 w-8 flex items-center justify-center text-sm">2</Badge>
|
||||
<h2 className="text-2xl font-semibold">Get Wallet Details</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Retrieve the wallet address and deployment status at any time.
|
||||
</p>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`export async function getWallet(address: string) {
|
||||
const res = await fetch(\`\${BASE_URL}/wallets/\${address}\`, {
|
||||
headers: { "X-API-Key": BOOTNODE_API_KEY },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(\`Failed to get wallet: \${error.error.message}\`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Response shape:
|
||||
// {
|
||||
// "address": "0x7A0b...ABCD",
|
||||
// "owner": "0xd8dA...6045",
|
||||
// "chain": "base",
|
||||
// "status": "counterfactual", // or "deployed"
|
||||
// "factory": "0x5FF1...2789",
|
||||
// "nonce": 0,
|
||||
// "created_at": "2026-01-15T10:30:00Z"
|
||||
// }`}</code>
|
||||
</pre>
|
||||
</section>
|
||||
|
||||
{/* Step 3 */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge className="rounded-full h-8 w-8 flex items-center justify-center text-sm">3</Badge>
|
||||
<h2 className="text-2xl font-semibold">Fund the Wallet</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Before sending transactions without gas sponsorship, your smart wallet needs
|
||||
funds. Send ETH (or the native token) to the smart wallet address from any
|
||||
wallet or exchange. The address works even before on-chain deployment.
|
||||
</p>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Alternatively, use gas sponsorship to skip funding entirely. See step 4.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`// Check the wallet balance via RPC
|
||||
export async function getBalance(address: string, chain: string) {
|
||||
const res = await fetch(\`\${BASE_URL}/rpc/\${chain}/mainnet\`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": BOOTNODE_API_KEY,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "eth_getBalance",
|
||||
params: [address, "latest"],
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
const balanceWei = BigInt(data.result);
|
||||
const balanceEth = Number(balanceWei) / 1e18;
|
||||
return { wei: balanceWei.toString(), eth: balanceEth.toFixed(6) };
|
||||
}
|
||||
|
||||
// const balance = await getBalance(wallet.address, "base");
|
||||
// console.log(balance);
|
||||
// => { wei: "50000000000000000", eth: "0.050000" }`}</code>
|
||||
</pre>
|
||||
</section>
|
||||
|
||||
{/* Step 4 */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge className="rounded-full h-8 w-8 flex items-center justify-center text-sm">4</Badge>
|
||||
<h2 className="text-2xl font-semibold">Send a Gasless Transaction</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Build a UserOperation, get it sponsored by the Bootnode paymaster, and submit
|
||||
it through the bundler. The user pays zero gas.
|
||||
</p>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`import { encodeFunctionData, parseAbi } from "viem";
|
||||
|
||||
const ERC20_ABI = parseAbi([
|
||||
"function transfer(address to, uint256 amount) returns (bool)",
|
||||
]);
|
||||
|
||||
export async function sendGaslessTransfer({
|
||||
walletAddress,
|
||||
tokenContract,
|
||||
to,
|
||||
amount,
|
||||
chain,
|
||||
policyId,
|
||||
}: {
|
||||
walletAddress: string;
|
||||
tokenContract: string;
|
||||
to: string;
|
||||
amount: bigint;
|
||||
chain: string;
|
||||
policyId: string;
|
||||
}) {
|
||||
// 1. Encode the ERC-20 transfer call
|
||||
const callData = encodeFunctionData({
|
||||
abi: ERC20_ABI,
|
||||
functionName: "transfer",
|
||||
args: [to as \`0x\${string}\`, amount],
|
||||
});
|
||||
|
||||
// 2. Build the UserOperation
|
||||
const userOp = {
|
||||
sender: walletAddress,
|
||||
nonce: "0x0", // Bootnode auto-manages nonces
|
||||
callData: callData,
|
||||
callGasLimit: "0x30000",
|
||||
verificationGasLimit: "0x50000",
|
||||
preVerificationGas: "0xc350",
|
||||
maxFeePerGas: "0x3b9aca00",
|
||||
maxPriorityFeePerGas: "0x59682f00",
|
||||
};
|
||||
|
||||
// 3. Get paymaster sponsorship
|
||||
const sponsorRes = await fetch(\`\${BASE_URL}/gas/sponsor\`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": BOOTNODE_API_KEY,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
chain,
|
||||
policy_id: policyId,
|
||||
user_operation: userOp,
|
||||
}),
|
||||
});
|
||||
|
||||
const sponsorData = await sponsorRes.json();
|
||||
|
||||
// 4. Send via bundler
|
||||
const bundlerRes = await fetch(\`\${BASE_URL}/bundler/\${chain}/mainnet\`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": BOOTNODE_API_KEY,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "eth_sendUserOperation",
|
||||
params: [
|
||||
{
|
||||
...userOp,
|
||||
paymasterAndData: sponsorData.paymasterData,
|
||||
signature: "0x", // Sign with owner's key in production
|
||||
},
|
||||
"0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789", // EntryPoint
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await bundlerRes.json();
|
||||
return result.result; // UserOperation hash
|
||||
}
|
||||
|
||||
// Usage:
|
||||
// const opHash = await sendGaslessTransfer({
|
||||
// walletAddress: "0x7A0b...ABCD",
|
||||
// tokenContract: "0xA0b8...eB48", // USDC on Base
|
||||
// to: "0x9876...5432",
|
||||
// amount: 1000000n, // 1 USDC (6 decimals)
|
||||
// chain: "base",
|
||||
// policyId: "gp_x1y2z3w4v5u6",
|
||||
// });
|
||||
// console.log("UserOp hash:", opHash);`}</code>
|
||||
</pre>
|
||||
</section>
|
||||
|
||||
{/* Full Next.js Example */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold">Full Next.js API Route Example</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Here is a complete Next.js API route that creates a wallet and sends a
|
||||
sponsored transaction:
|
||||
</p>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`// app/api/wallet/create/route.ts
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const BOOTNODE_API_KEY = process.env.BOOTNODE_API_KEY!;
|
||||
const BASE_URL = "https://api.bootnode.dev/v1";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { ownerAddress } = await req.json();
|
||||
|
||||
if (!ownerAddress) {
|
||||
return NextResponse.json(
|
||||
{ error: "ownerAddress is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Create the smart wallet
|
||||
const walletRes = await fetch(\`\${BASE_URL}/wallets/create\`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": BOOTNODE_API_KEY,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
owner: ownerAddress,
|
||||
chain: "base",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!walletRes.ok) {
|
||||
const error = await walletRes.json();
|
||||
return NextResponse.json(
|
||||
{ error: error.error.message },
|
||||
{ status: walletRes.status }
|
||||
);
|
||||
}
|
||||
|
||||
const wallet = await walletRes.json();
|
||||
|
||||
return NextResponse.json({
|
||||
address: wallet.address,
|
||||
owner: wallet.owner,
|
||||
chain: wallet.chain,
|
||||
status: wallet.status,
|
||||
});
|
||||
}`}</code>
|
||||
</pre>
|
||||
</section>
|
||||
|
||||
{/* Next Steps */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold">Next Steps</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{[
|
||||
{ title: "Account Abstraction", href: "/docs/aa/quickstart", desc: "Deep dive into ERC-4337" },
|
||||
{ title: "Gas Manager", href: "/docs/api", desc: "Set up gas sponsorship policies" },
|
||||
{ title: "Webhooks", href: "/docs/webhooks/quickstart", desc: "Monitor wallet activity" },
|
||||
{ title: "API Reference", href: "/docs/api", desc: "Full wallet API docs" },
|
||||
].map((item) => (
|
||||
<Link
|
||||
key={item.title}
|
||||
href={item.href}
|
||||
className="flex items-center justify-between p-4 border rounded-lg hover:bg-muted transition-colors group"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">{item.title}</p>
|
||||
<p className="text-sm text-muted-foreground">{item.desc}</p>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</DocsLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
import Link from "next/link"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { DocsLayout } from "@/components/docs-layout"
|
||||
import { ArrowRight } from "lucide-react"
|
||||
|
||||
export const metadata = {
|
||||
title: "Webhooks Quickstart",
|
||||
description: "Set up real-time blockchain event notifications with Bootnode Webhooks.",
|
||||
}
|
||||
|
||||
export default function WebhooksQuickstartPage() {
|
||||
return (
|
||||
<DocsLayout>
|
||||
<div className="space-y-10">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold mb-4">Webhooks Quickstart</h1>
|
||||
<p className="text-lg text-muted-foreground">
|
||||
Receive real-time notifications when on-chain events happen. No polling required.
|
||||
Bootnode will POST a signed JSON payload to your endpoint whenever a matching event
|
||||
occurs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Event Types */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold">Supported Event Types</h2>
|
||||
<div className="overflow-x-auto border rounded-lg">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="text-left p-3 font-medium">Event Type</th>
|
||||
<th className="text-left p-3 font-medium">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b">
|
||||
<td className="p-3 font-mono text-xs">address_activity</td>
|
||||
<td className="p-3 text-muted-foreground">Any transaction involving specified addresses (sends, receives, contract interactions)</td>
|
||||
</tr>
|
||||
<tr className="border-b">
|
||||
<td className="p-3 font-mono text-xs">token_transfer</td>
|
||||
<td className="p-3 text-muted-foreground">ERC-20 token transfers to/from specified addresses or contracts</td>
|
||||
</tr>
|
||||
<tr className="border-b">
|
||||
<td className="p-3 font-mono text-xs">nft_transfer</td>
|
||||
<td className="p-3 text-muted-foreground">ERC-721 and ERC-1155 NFT transfers</td>
|
||||
</tr>
|
||||
<tr className="border-b">
|
||||
<td className="p-3 font-mono text-xs">mined_transaction</td>
|
||||
<td className="p-3 text-muted-foreground">Specific transaction hash has been mined and confirmed</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="p-3 font-mono text-xs">log</td>
|
||||
<td className="p-3 text-muted-foreground">Contract event logs matching specific topics and addresses</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Step 1 */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge className="rounded-full h-8 w-8 flex items-center justify-center text-sm">1</Badge>
|
||||
<h2 className="text-2xl font-semibold">Create a Webhook Subscription</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Register your endpoint and specify which events you want to receive.
|
||||
Bootnode returns a <code className="bg-muted px-1.5 py-0.5 rounded text-sm">signing_secret</code>{" "}
|
||||
that you will use to verify webhook payloads.
|
||||
</p>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`curl -X POST https://api.bootnode.dev/v1/webhooks \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "X-API-Key: YOUR_API_KEY" \\
|
||||
-d '{
|
||||
"url": "https://myapp.com/api/webhooks/bootnode",
|
||||
"chain": "ethereum",
|
||||
"event_type": "address_activity",
|
||||
"config": {
|
||||
"addresses": [
|
||||
"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
|
||||
"0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B"
|
||||
]
|
||||
}
|
||||
}'`}</code>
|
||||
</pre>
|
||||
<p className="text-sm text-muted-foreground">Response:</p>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`{
|
||||
"id": "wh_a1b2c3d4e5f6",
|
||||
"url": "https://myapp.com/api/webhooks/bootnode",
|
||||
"chain": "ethereum",
|
||||
"event_type": "address_activity",
|
||||
"config": {
|
||||
"addresses": [
|
||||
"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
|
||||
"0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B"
|
||||
]
|
||||
},
|
||||
"signing_secret": "whsec_k7m9p2q4r6s8t0v2w4x6y8z0",
|
||||
"status": "active",
|
||||
"created_at": "2026-01-15T10:30:00Z"
|
||||
}`}</code>
|
||||
</pre>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm font-medium">Important</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Save the <code className="bg-muted px-1.5 py-0.5 rounded text-xs">signing_secret</code> securely.
|
||||
It is only returned once during creation. You will need it to verify incoming webhook signatures.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Step 2 */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge className="rounded-full h-8 w-8 flex items-center justify-center text-sm">2</Badge>
|
||||
<h2 className="text-2xl font-semibold">Set Up Your Webhook Endpoint</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Create an Express.js server (or any HTTP server) to receive webhook events.
|
||||
Bootnode sends POST requests with a JSON body and an HMAC signature in the headers.
|
||||
</p>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`// server.ts
|
||||
import express from "express";
|
||||
import crypto from "crypto";
|
||||
|
||||
const app = express();
|
||||
const SIGNING_SECRET = process.env.WEBHOOK_SIGNING_SECRET!;
|
||||
// "whsec_k7m9p2q4r6s8t0v2w4x6y8z0"
|
||||
|
||||
// IMPORTANT: Use raw body for signature verification
|
||||
app.post(
|
||||
"/api/webhooks/bootnode",
|
||||
express.raw({ type: "application/json" }),
|
||||
(req, res) => {
|
||||
const signature = req.headers["x-bootnode-signature"] as string;
|
||||
const timestamp = req.headers["x-bootnode-timestamp"] as string;
|
||||
const body = req.body as Buffer;
|
||||
|
||||
// 1. Verify the signature
|
||||
if (!verifySignature(body, signature, timestamp)) {
|
||||
console.error("Invalid webhook signature");
|
||||
return res.status(401).json({ error: "Invalid signature" });
|
||||
}
|
||||
|
||||
// 2. Parse the event
|
||||
const event = JSON.parse(body.toString());
|
||||
console.log("Received event:", event.event_type, event.data);
|
||||
|
||||
// 3. Handle the event
|
||||
switch (event.event_type) {
|
||||
case "address_activity":
|
||||
handleAddressActivity(event.data);
|
||||
break;
|
||||
case "token_transfer":
|
||||
handleTokenTransfer(event.data);
|
||||
break;
|
||||
case "nft_transfer":
|
||||
handleNftTransfer(event.data);
|
||||
break;
|
||||
default:
|
||||
console.log("Unhandled event type:", event.event_type);
|
||||
}
|
||||
|
||||
// 4. Return 200 to acknowledge receipt
|
||||
res.status(200).json({ received: true });
|
||||
}
|
||||
);
|
||||
|
||||
app.listen(3001, () => {
|
||||
console.log("Webhook server listening on port 3001");
|
||||
});`}</code>
|
||||
</pre>
|
||||
</section>
|
||||
|
||||
{/* Step 3 */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge className="rounded-full h-8 w-8 flex items-center justify-center text-sm">3</Badge>
|
||||
<h2 className="text-2xl font-semibold">Verify the HMAC Signature</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Every webhook request includes two headers for verification:{" "}
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded text-sm">X-Bootnode-Signature</code> and{" "}
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded text-sm">X-Bootnode-Timestamp</code>.
|
||||
The signature is a HMAC-SHA256 hex digest of{" "}
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded text-sm">{`{timestamp}.{body}`}</code>.
|
||||
</p>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`function verifySignature(
|
||||
body: Buffer,
|
||||
signature: string,
|
||||
timestamp: string
|
||||
): boolean {
|
||||
// 1. Reject old timestamps (prevent replay attacks)
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const ts = parseInt(timestamp, 10);
|
||||
if (Math.abs(now - ts) > 300) {
|
||||
// 5-minute tolerance
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. Compute expected signature
|
||||
const payload = \`\${timestamp}.\${body.toString()}\`;
|
||||
const expected = crypto
|
||||
.createHmac("sha256", SIGNING_SECRET)
|
||||
.update(payload)
|
||||
.digest("hex");
|
||||
|
||||
// 3. Constant-time comparison
|
||||
return crypto.timingSafeEqual(
|
||||
Buffer.from(signature),
|
||||
Buffer.from(expected)
|
||||
);
|
||||
}`}</code>
|
||||
</pre>
|
||||
</section>
|
||||
|
||||
{/* Step 4 */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge className="rounded-full h-8 w-8 flex items-center justify-center text-sm">4</Badge>
|
||||
<h2 className="text-2xl font-semibold">Handle Event Types</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Each event type has a specific payload structure. Here are handlers for the
|
||||
most common event types:
|
||||
</p>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`interface AddressActivityEvent {
|
||||
chain: string;
|
||||
block_number: number;
|
||||
tx_hash: string;
|
||||
from: string;
|
||||
to: string;
|
||||
value: string; // wei as decimal string
|
||||
asset: string; // "ETH" or token symbol
|
||||
contract: string | null; // null for native transfers
|
||||
direction: "incoming" | "outgoing";
|
||||
}
|
||||
|
||||
function handleAddressActivity(data: AddressActivityEvent) {
|
||||
console.log(
|
||||
\`[\${data.chain}] \${data.direction} \${data.asset}: \` +
|
||||
\`\${data.from} -> \${data.to} (\${data.value} wei)\`
|
||||
);
|
||||
|
||||
// Example: notify user of incoming transfer
|
||||
if (data.direction === "incoming") {
|
||||
notifyUser(data.to, \`Received \${data.asset} from \${data.from}\`);
|
||||
}
|
||||
}
|
||||
|
||||
interface TokenTransferEvent {
|
||||
chain: string;
|
||||
block_number: number;
|
||||
tx_hash: string;
|
||||
contract: string;
|
||||
from: string;
|
||||
to: string;
|
||||
value: string;
|
||||
symbol: string;
|
||||
decimals: number;
|
||||
}
|
||||
|
||||
function handleTokenTransfer(data: TokenTransferEvent) {
|
||||
const formatted = (
|
||||
Number(BigInt(data.value)) / Math.pow(10, data.decimals)
|
||||
).toFixed(data.decimals);
|
||||
console.log(
|
||||
\`[\${data.chain}] Token transfer: \${formatted} \${data.symbol}\`
|
||||
);
|
||||
}
|
||||
|
||||
interface NftTransferEvent {
|
||||
chain: string;
|
||||
block_number: number;
|
||||
tx_hash: string;
|
||||
contract: string;
|
||||
from: string;
|
||||
to: string;
|
||||
token_id: string;
|
||||
standard: "ERC721" | "ERC1155";
|
||||
quantity: number; // always 1 for ERC721
|
||||
}
|
||||
|
||||
function handleNftTransfer(data: NftTransferEvent) {
|
||||
console.log(
|
||||
\`[\${data.chain}] NFT transfer: #\${data.token_id} \` +
|
||||
\`(\${data.standard}) from \${data.from} to \${data.to}\`
|
||||
);
|
||||
}
|
||||
|
||||
function notifyUser(address: string, message: string) {
|
||||
// Your notification logic (email, push, in-app, etc.)
|
||||
console.log(\`Notify \${address}: \${message}\`);
|
||||
}`}</code>
|
||||
</pre>
|
||||
</section>
|
||||
|
||||
{/* Example Payload */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold">Example Webhook Payload</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Here is a full example of what Bootnode sends to your endpoint:
|
||||
</p>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`// Headers:
|
||||
// X-Bootnode-Signature: 5a2f8c9d1e3b4a6f7c8d9e0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b
|
||||
// X-Bootnode-Timestamp: 1706400000
|
||||
// Content-Type: application/json
|
||||
|
||||
// Body:
|
||||
{
|
||||
"id": "evt_z9y8x7w6v5u4",
|
||||
"event_type": "address_activity",
|
||||
"webhook_id": "wh_a1b2c3d4e5f6",
|
||||
"created_at": "2026-01-15T10:35:22Z",
|
||||
"data": {
|
||||
"chain": "ethereum",
|
||||
"block_number": 20574335,
|
||||
"tx_hash": "0xabc123def456789...",
|
||||
"from": "0x1234567890abcdef1234567890abcdef12345678",
|
||||
"to": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
|
||||
"value": "1000000000000000000",
|
||||
"asset": "ETH",
|
||||
"contract": null,
|
||||
"direction": "incoming"
|
||||
}
|
||||
}`}</code>
|
||||
</pre>
|
||||
</section>
|
||||
|
||||
{/* Retry Policy */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold">Retry Policy</h2>
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
If your endpoint returns a non-2xx status code or does not respond within 15 seconds,
|
||||
Bootnode retries the delivery with exponential backoff:
|
||||
</p>
|
||||
<div className="overflow-x-auto border rounded-lg">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="text-left p-3 font-medium">Attempt</th>
|
||||
<th className="text-left p-3 font-medium">Delay</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b"><td className="p-3">1st retry</td><td className="p-3">10 seconds</td></tr>
|
||||
<tr className="border-b"><td className="p-3">2nd retry</td><td className="p-3">1 minute</td></tr>
|
||||
<tr className="border-b"><td className="p-3">3rd retry</td><td className="p-3">10 minutes</td></tr>
|
||||
<tr className="border-b"><td className="p-3">4th retry</td><td className="p-3">1 hour</td></tr>
|
||||
<tr><td className="p-3">5th retry</td><td className="p-3">6 hours</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
After 5 failed retries, the webhook is paused and you receive an email notification.
|
||||
You can resume it from the dashboard or via the PATCH endpoint.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Next Steps */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold">Next Steps</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{[
|
||||
{ title: "API Reference", href: "/docs/api", desc: "Full webhook API endpoints" },
|
||||
{ title: "WebSockets", href: "/docs/websockets/quickstart", desc: "Real-time streaming alternative" },
|
||||
{ title: "Smart Wallets", href: "/docs/wallets/quickstart", desc: "Monitor smart wallet activity" },
|
||||
{ title: "Examples", href: "/docs/examples", desc: "More webhook use cases" },
|
||||
].map((item) => (
|
||||
<Link
|
||||
key={item.title}
|
||||
href={item.href}
|
||||
className="flex items-center justify-between p-4 border rounded-lg hover:bg-muted transition-colors group"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">{item.title}</p>
|
||||
<p className="text-sm text-muted-foreground">{item.desc}</p>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</DocsLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
import Link from "next/link"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { DocsLayout } from "@/components/docs-layout"
|
||||
import { ArrowRight } from "lucide-react"
|
||||
|
||||
export const metadata = {
|
||||
title: "WebSockets Quickstart",
|
||||
description: "Subscribe to real-time blockchain data with Bootnode WebSockets.",
|
||||
}
|
||||
|
||||
export default function WebSocketsQuickstartPage() {
|
||||
return (
|
||||
<DocsLayout>
|
||||
<div className="space-y-10">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold mb-4">WebSockets Quickstart</h1>
|
||||
<p className="text-lg text-muted-foreground">
|
||||
Subscribe to real-time blockchain events over a persistent WebSocket connection.
|
||||
Receive new blocks, logs, and pending transactions the moment they appear.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Subscription Types */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold">Available Subscriptions</h2>
|
||||
<div className="overflow-x-auto border rounded-lg">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="text-left p-3 font-medium">Subscription</th>
|
||||
<th className="text-left p-3 font-medium">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b">
|
||||
<td className="p-3 font-mono text-xs">newHeads</td>
|
||||
<td className="p-3 text-muted-foreground">New block headers as they are mined</td>
|
||||
</tr>
|
||||
<tr className="border-b">
|
||||
<td className="p-3 font-mono text-xs">logs</td>
|
||||
<td className="p-3 text-muted-foreground">Smart contract event logs with optional topic/address filters</td>
|
||||
</tr>
|
||||
<tr className="border-b">
|
||||
<td className="p-3 font-mono text-xs">newPendingTransactions</td>
|
||||
<td className="p-3 text-muted-foreground">Transaction hashes as they enter the mempool</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="p-3 font-mono text-xs">syncing</td>
|
||||
<td className="p-3 text-muted-foreground">Node sync status changes</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Step 1 */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge className="rounded-full h-8 w-8 flex items-center justify-center text-sm">1</Badge>
|
||||
<h2 className="text-2xl font-semibold">Connect to the WebSocket Endpoint</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
The WebSocket URL follows the same chain/network pattern as the REST API.
|
||||
Pass your API key as a query parameter.
|
||||
</p>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`wss://api.bootnode.dev/v1/ws/{chain}/{network}?apiKey=YOUR_API_KEY
|
||||
|
||||
// Examples:
|
||||
// wss://api.bootnode.dev/v1/ws/ethereum/mainnet?apiKey=bn_live_...
|
||||
// wss://api.bootnode.dev/v1/ws/base/mainnet?apiKey=bn_live_...
|
||||
// wss://api.bootnode.dev/v1/ws/polygon/mainnet?apiKey=bn_live_...`}</code>
|
||||
</pre>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`// Node.js / Browser
|
||||
const ws = new WebSocket(
|
||||
"wss://api.bootnode.dev/v1/ws/ethereum/mainnet?apiKey=" +
|
||||
process.env.BOOTNODE_API_KEY
|
||||
);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log("Connected to Bootnode WebSocket");
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error("WebSocket error:", error);
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
console.log(\`WebSocket closed: code=\${event.code} reason=\${event.reason}\`);
|
||||
};`}</code>
|
||||
</pre>
|
||||
</section>
|
||||
|
||||
{/* Step 2 */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge className="rounded-full h-8 w-8 flex items-center justify-center text-sm">2</Badge>
|
||||
<h2 className="text-2xl font-semibold">Subscribe to New Block Headers</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
The <code className="bg-muted px-1.5 py-0.5 rounded text-sm">newHeads</code> subscription
|
||||
emits a message every time a new block is mined. Each message contains the block
|
||||
header with number, hash, timestamp, gas used, and base fee.
|
||||
</p>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`const ws = new WebSocket(
|
||||
"wss://api.bootnode.dev/v1/ws/ethereum/mainnet?apiKey=" +
|
||||
process.env.BOOTNODE_API_KEY
|
||||
);
|
||||
|
||||
ws.onopen = () => {
|
||||
// Subscribe to new block headers
|
||||
ws.send(JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "eth_subscribe",
|
||||
params: ["newHeads"],
|
||||
}));
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
|
||||
// Subscription confirmation
|
||||
if (message.id === 1) {
|
||||
console.log("Subscribed! ID:", message.result);
|
||||
// => "Subscribed! ID: 0x1a2b3c4d"
|
||||
return;
|
||||
}
|
||||
|
||||
// New block header
|
||||
if (message.method === "eth_subscription") {
|
||||
const block = message.params.result;
|
||||
console.log("New block:", {
|
||||
number: parseInt(block.number, 16),
|
||||
hash: block.hash,
|
||||
timestamp: new Date(parseInt(block.timestamp, 16) * 1000),
|
||||
gasUsed: parseInt(block.gasUsed, 16),
|
||||
gasLimit: parseInt(block.gasLimit, 16),
|
||||
baseFeePerGas: parseInt(block.baseFeePerGas, 16) / 1e9 + " gwei",
|
||||
transactionCount: block.transactions?.length ?? "N/A",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Example output:
|
||||
// New block: {
|
||||
// number: 20574335,
|
||||
// hash: "0xabc123...",
|
||||
// timestamp: 2026-01-15T10:35:22.000Z,
|
||||
// gasUsed: 15000000,
|
||||
// gasLimit: 30000000,
|
||||
// baseFeePerGas: "12.5 gwei",
|
||||
// transactionCount: 142
|
||||
// }`}</code>
|
||||
</pre>
|
||||
</section>
|
||||
|
||||
{/* Step 3 */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge className="rounded-full h-8 w-8 flex items-center justify-center text-sm">3</Badge>
|
||||
<h2 className="text-2xl font-semibold">Subscribe to Logs with Filters</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Subscribe to smart contract events by specifying contract addresses and/or
|
||||
event topic signatures. This example monitors USDC Transfer events.
|
||||
</p>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`const USDC_ADDRESS = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
|
||||
// keccak256("Transfer(address,address,uint256)")
|
||||
const TRANSFER_TOPIC =
|
||||
"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
|
||||
|
||||
ws.onopen = () => {
|
||||
// Subscribe to USDC Transfer events
|
||||
ws.send(JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 2,
|
||||
method: "eth_subscribe",
|
||||
params: [
|
||||
"logs",
|
||||
{
|
||||
address: USDC_ADDRESS,
|
||||
topics: [TRANSFER_TOPIC],
|
||||
},
|
||||
],
|
||||
}));
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
|
||||
if (message.id === 2) {
|
||||
console.log("Subscribed to USDC transfers! ID:", message.result);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.method === "eth_subscription") {
|
||||
const log = message.params.result;
|
||||
|
||||
// Decode Transfer event
|
||||
const from = "0x" + log.topics[1].slice(26);
|
||||
const to = "0x" + log.topics[2].slice(26);
|
||||
const amount = BigInt(log.data);
|
||||
const usdc = Number(amount) / 1e6; // USDC has 6 decimals
|
||||
|
||||
console.log(\`USDC Transfer: \${from} -> \${to}: \$\${usdc.toFixed(2)}\`);
|
||||
console.log(\` Block: \${parseInt(log.blockNumber, 16)}\`);
|
||||
console.log(\` Tx: \${log.transactionHash}\`);
|
||||
}
|
||||
};
|
||||
|
||||
// Example output:
|
||||
// USDC Transfer: 0xd8dA...6045 -> 0x9876...5432: $1500.00
|
||||
// Block: 20574335
|
||||
// Tx: 0xabc123...`}</code>
|
||||
</pre>
|
||||
</section>
|
||||
|
||||
{/* Step 4 */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge className="rounded-full h-8 w-8 flex items-center justify-center text-sm">4</Badge>
|
||||
<h2 className="text-2xl font-semibold">Subscribe to Pending Transactions</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Monitor the mempool for new pending transactions. Useful for MEV detection,
|
||||
frontrunning protection, or real-time transaction feeds.
|
||||
</p>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`ws.onopen = () => {
|
||||
// Subscribe to pending transactions
|
||||
ws.send(JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 3,
|
||||
method: "eth_subscribe",
|
||||
params: ["newPendingTransactions"],
|
||||
}));
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
|
||||
if (message.id === 3) {
|
||||
console.log("Subscribed to pending txs! ID:", message.result);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.method === "eth_subscription") {
|
||||
const txHash = message.params.result;
|
||||
console.log("Pending tx:", txHash);
|
||||
// => "Pending tx: 0xabc123def456..."
|
||||
|
||||
// Optionally fetch full transaction details via RPC
|
||||
// ws.send(JSON.stringify({
|
||||
// jsonrpc: "2.0",
|
||||
// id: Date.now(),
|
||||
// method: "eth_getTransactionByHash",
|
||||
// params: [txHash],
|
||||
// }));
|
||||
}
|
||||
};`}</code>
|
||||
</pre>
|
||||
</section>
|
||||
|
||||
{/* Step 5 */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge className="rounded-full h-8 w-8 flex items-center justify-center text-sm">5</Badge>
|
||||
<h2 className="text-2xl font-semibold">Handle Reconnection</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
WebSocket connections can drop due to network issues or server maintenance.
|
||||
Always implement reconnection logic with exponential backoff.
|
||||
</p>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`class BootnodeWebSocket {
|
||||
private ws: WebSocket | null = null;
|
||||
private url: string;
|
||||
private subscriptions: Array<{ id: number; params: any[] }> = [];
|
||||
private reconnectAttempts = 0;
|
||||
private maxReconnectAttempts = 10;
|
||||
private baseDelay = 1000;
|
||||
private onMessage: (data: any) => void;
|
||||
|
||||
constructor(
|
||||
chain: string,
|
||||
network: string,
|
||||
apiKey: string,
|
||||
onMessage: (data: any) => void
|
||||
) {
|
||||
this.url =
|
||||
\`wss://api.bootnode.dev/v1/ws/\${chain}/\${network}?apiKey=\${apiKey}\`;
|
||||
this.onMessage = onMessage;
|
||||
this.connect();
|
||||
}
|
||||
|
||||
private connect() {
|
||||
this.ws = new WebSocket(this.url);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
console.log("WebSocket connected");
|
||||
this.reconnectAttempts = 0;
|
||||
|
||||
// Re-subscribe to all previous subscriptions
|
||||
for (const sub of this.subscriptions) {
|
||||
this.ws!.send(JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: sub.id,
|
||||
method: "eth_subscribe",
|
||||
params: sub.params,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
this.onMessage(data);
|
||||
};
|
||||
|
||||
this.ws.onclose = (event) => {
|
||||
console.log(\`WebSocket closed: \${event.code} \${event.reason}\`);
|
||||
this.reconnect();
|
||||
};
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
console.error("WebSocket error:", error);
|
||||
// onclose will fire after onerror, triggering reconnect
|
||||
};
|
||||
}
|
||||
|
||||
private reconnect() {
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
console.error("Max reconnection attempts reached. Giving up.");
|
||||
return;
|
||||
}
|
||||
|
||||
const delay = this.baseDelay * Math.pow(2, this.reconnectAttempts);
|
||||
const jitter = Math.random() * 1000;
|
||||
this.reconnectAttempts++;
|
||||
|
||||
console.log(
|
||||
\`Reconnecting in \${delay + jitter}ms \` +
|
||||
\`(attempt \${this.reconnectAttempts}/\${this.maxReconnectAttempts})\`
|
||||
);
|
||||
|
||||
setTimeout(() => this.connect(), delay + jitter);
|
||||
}
|
||||
|
||||
subscribe(id: number, params: any[]) {
|
||||
this.subscriptions.push({ id, params });
|
||||
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
method: "eth_subscribe",
|
||||
params,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
unsubscribe(subscriptionId: string) {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: Date.now(),
|
||||
method: "eth_unsubscribe",
|
||||
params: [subscriptionId],
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.maxReconnectAttempts = 0; // Prevent reconnection
|
||||
this.ws?.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Usage:
|
||||
const client = new BootnodeWebSocket(
|
||||
"ethereum",
|
||||
"mainnet",
|
||||
process.env.BOOTNODE_API_KEY!,
|
||||
(data) => {
|
||||
if (data.method === "eth_subscription") {
|
||||
console.log("Event:", data.params.result);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Subscribe to new blocks
|
||||
client.subscribe(1, ["newHeads"]);
|
||||
|
||||
// Subscribe to USDC transfers
|
||||
client.subscribe(2, ["logs", {
|
||||
address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
|
||||
topics: ["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"],
|
||||
}]);
|
||||
|
||||
// Clean shutdown
|
||||
process.on("SIGINT", () => {
|
||||
client.close();
|
||||
process.exit(0);
|
||||
});`}</code>
|
||||
</pre>
|
||||
</section>
|
||||
|
||||
{/* Unsubscribe */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold">Unsubscribing</h2>
|
||||
<p className="text-muted-foreground">
|
||||
To stop receiving events for a subscription, send an{" "}
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded text-sm">eth_unsubscribe</code> request
|
||||
with the subscription ID returned from the initial subscribe call.
|
||||
</p>
|
||||
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
|
||||
<code>{`// Unsubscribe
|
||||
ws.send(JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 99,
|
||||
method: "eth_unsubscribe",
|
||||
params: ["0x1a2b3c4d"], // subscription ID from eth_subscribe response
|
||||
}));
|
||||
|
||||
// Response:
|
||||
// { "jsonrpc": "2.0", "id": 99, "result": true }`}</code>
|
||||
</pre>
|
||||
</section>
|
||||
|
||||
{/* Connection Limits */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold">Connection Limits</h2>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="overflow-x-auto border rounded-lg">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="text-left p-3 font-medium">Plan</th>
|
||||
<th className="text-left p-3 font-medium">Concurrent Connections</th>
|
||||
<th className="text-left p-3 font-medium">Subscriptions per Connection</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b">
|
||||
<td className="p-3">Free</td>
|
||||
<td className="p-3">2</td>
|
||||
<td className="p-3">5</td>
|
||||
</tr>
|
||||
<tr className="border-b">
|
||||
<td className="p-3">Growth</td>
|
||||
<td className="p-3">10</td>
|
||||
<td className="p-3">50</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="p-3">Enterprise</td>
|
||||
<td className="p-3">Custom</td>
|
||||
<td className="p-3">Custom</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Next Steps */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold">Next Steps</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{[
|
||||
{ title: "Webhooks", href: "/docs/webhooks/quickstart", desc: "Server-side event notifications" },
|
||||
{ title: "API Reference", href: "/docs/api", desc: "Full WebSocket API docs" },
|
||||
{ title: "Examples", href: "/docs/examples", desc: "More streaming examples" },
|
||||
{ title: "SDKs", href: "/docs/sdks", desc: "Client libraries with built-in WS" },
|
||||
].map((item) => (
|
||||
<Link
|
||||
key={item.title}
|
||||
href={item.href}
|
||||
className="flex items-center justify-between p-4 border rounded-lg hover:bg-muted transition-colors group"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">{item.title}</p>
|
||||
<p className="text-sm text-muted-foreground">{item.desc}</p>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</DocsLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@plugin "tailwindcss-animate";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--animate-accordion-down: accordion-down 0.2s ease-out;
|
||||
--animate-accordion-up: accordion-up 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes accordion-down {
|
||||
from {
|
||||
height: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes accordion-up {
|
||||
from {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.5rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-synthesis-weight: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
.container {
|
||||
margin-inline: auto;
|
||||
width: 100%;
|
||||
max-width: 1400px;
|
||||
padding-inline: 2rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { Metadata } from "next"
|
||||
import { GeistSans } from "geist/font/sans"
|
||||
import { GeistMono } from "geist/font/mono"
|
||||
import { ThemeProvider } from "@/components/theme-provider"
|
||||
import { AuthProvider } from "@/lib/auth"
|
||||
import { Toaster } from "@/components/ui/sonner"
|
||||
import "@/app/globals.css"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: "Bootnode - Blockchain Infrastructure for Developers",
|
||||
template: "%s | Bootnode",
|
||||
},
|
||||
description:
|
||||
"The complete blockchain development platform. Multi-chain RPC, Token APIs, NFT APIs, Smart Wallets, Webhooks, and more. Build faster with Bootnode.",
|
||||
keywords: [
|
||||
"blockchain",
|
||||
"RPC",
|
||||
"API",
|
||||
"Web3",
|
||||
"Ethereum",
|
||||
"Solana",
|
||||
"NFT",
|
||||
"DeFi",
|
||||
"smart contracts",
|
||||
"developer tools",
|
||||
],
|
||||
authors: [{ name: "Bootnode" }],
|
||||
openGraph: {
|
||||
type: "website",
|
||||
locale: "en_US",
|
||||
url: "https://bootnode.dev",
|
||||
siteName: "Bootnode",
|
||||
title: "Bootnode - Blockchain Infrastructure for Developers",
|
||||
description:
|
||||
"The complete blockchain development platform. Multi-chain RPC, Token APIs, NFT APIs, Smart Wallets, Webhooks, and more.",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "Bootnode - Blockchain Infrastructure for Developers",
|
||||
description:
|
||||
"The complete blockchain development platform. Multi-chain RPC, Token APIs, NFT APIs, Smart Wallets, Webhooks, and more.",
|
||||
},
|
||||
icons: {
|
||||
icon: "/logo/bootnode-icon.svg",
|
||||
apple: "/logo/bootnode-icon.svg",
|
||||
shortcut: "/logo/bootnode-icon.svg",
|
||||
},
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
suppressHydrationWarning
|
||||
className={`${GeistSans.variable} ${GeistMono.variable}`}
|
||||
>
|
||||
<body className="min-h-screen bg-background font-sans antialiased">
|
||||
<AuthProvider>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="light"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
{children}
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
export async function GET() {
|
||||
const content = `# Bootnode
|
||||
## Blockchain Infrastructure for Developers
|
||||
|
||||
> The complete blockchain development platform. Multi-chain RPC, Token APIs, NFT APIs, Smart Wallets, Webhooks, and more.
|
||||
|
||||
## Products
|
||||
- Node: Multi-chain RPC with 99.999% uptime across 100+ chains
|
||||
- Data: Token, NFT, and Transfer APIs
|
||||
- Wallets: ERC-4337 Smart Wallets with Account Abstraction
|
||||
- Webhooks: Real-time onchain event notifications
|
||||
- Gas Manager: Gas sponsorship and paymaster policies
|
||||
- Bundler: ERC-4337 bundler for UserOperations
|
||||
- Rollups: Launch custom rollups
|
||||
|
||||
## API Base URL
|
||||
https://api.bootnode.dev/v1
|
||||
|
||||
## Authentication
|
||||
X-API-Key: <your-api-key>
|
||||
|
||||
## Endpoints
|
||||
- POST /v1/rpc/{chain}/{network} - JSON-RPC proxy
|
||||
- GET /v1/tokens/{chain}/balances/{address} - Token balances
|
||||
- GET /v1/tokens/{chain}/metadata/{contract} - Token metadata
|
||||
- GET /v1/nfts/{chain}/metadata/{contract}/{token_id} - NFT metadata
|
||||
- GET /v1/nfts/{chain}/collection/{contract} - NFT collection info
|
||||
- GET /v1/nfts/{chain}/owned/{address} - NFTs owned by address
|
||||
- POST /v1/wallets/create - Create smart wallet
|
||||
- GET /v1/wallets/{address} - Get wallet details
|
||||
- GET /v1/wallets - List wallets
|
||||
- POST /v1/webhooks - Create webhook
|
||||
- GET /v1/webhooks - List webhooks
|
||||
- PATCH /v1/webhooks/{id} - Update webhook
|
||||
- DELETE /v1/webhooks/{id} - Delete webhook
|
||||
- GET /v1/gas/{chain}/prices - Gas prices
|
||||
- POST /v1/gas/policies - Create gas policy
|
||||
- POST /v1/gas/sponsor - Sponsor a UserOperation
|
||||
- POST /v1/bundler/{chain}/{network} - Bundler RPC
|
||||
- WS /v1/ws/{chain}/{network} - WebSocket subscriptions
|
||||
|
||||
## Supported Chains
|
||||
Ethereum, Solana, Base, Arbitrum, Polygon, Optimism, Avalanche, BNB Smart Chain, Lux Network, and 90+ more.
|
||||
|
||||
## Documentation
|
||||
https://bootnode.dev/docs
|
||||
`
|
||||
return new Response(content, {
|
||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Suspense } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Blocks, Loader2 } from "lucide-react"
|
||||
import { useAuth, HanzoLoginButton } from "@/lib/auth"
|
||||
|
||||
function LoginForm() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { user, isLoading: authLoading, isProduction, login, register } = useAuth()
|
||||
|
||||
const [email, setEmail] = React.useState("")
|
||||
const [password, setPassword] = React.useState("")
|
||||
const [name, setName] = React.useState("")
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const [error, setError] = React.useState("")
|
||||
const [mode, setMode] = React.useState<"login" | "register">("login")
|
||||
|
||||
// Redirect if already authenticated
|
||||
React.useEffect(() => {
|
||||
if (!authLoading && user) {
|
||||
const returnUrl = searchParams.get("returnUrl") || "/dashboard"
|
||||
router.push(returnUrl)
|
||||
}
|
||||
}, [user, authLoading, router, searchParams])
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError("")
|
||||
|
||||
try {
|
||||
if (mode === "register") {
|
||||
await register(email, password, name)
|
||||
} else {
|
||||
await login(email, password)
|
||||
}
|
||||
const returnUrl = searchParams.get("returnUrl") || "/dashboard"
|
||||
router.push(returnUrl)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Authentication failed")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Production mode: Show IAM login options
|
||||
if (isProduction) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-gradient-to-b from-background to-muted/20 px-4">
|
||||
<Link href="/" className="mb-8 flex items-center space-x-2">
|
||||
<Blocks className="h-8 w-8" />
|
||||
<span className="text-2xl font-bold">Bootnode</span>
|
||||
</Link>
|
||||
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-xl">Sign in to Bootnode</CardTitle>
|
||||
<CardDescription>
|
||||
Choose your identity provider to continue
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<HanzoLoginButton org="hanzo" />
|
||||
<Button variant="outline" className="w-full" onClick={() => window.location.href = `${process.env.NEXT_PUBLIC_IAM_URL || "https://iam.hanzo.ai"}/oauth2/authorize?response_type=code&client_id=${process.env.NEXT_PUBLIC_IAM_CLIENT_ID || "bootnode-platform"}&redirect_uri=${encodeURIComponent(window.location.origin + "/auth/callback")}&scope=openid+profile+email&state=zoo`}>
|
||||
Continue with Zoo ID
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-card px-2 text-muted-foreground">or</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => window.location.href = `${process.env.NEXT_PUBLIC_IAM_URL || "https://iam.hanzo.ai"}/oauth2/authorize?response_type=code&client_id=${process.env.NEXT_PUBLIC_IAM_CLIENT_ID || "bootnode-platform"}&redirect_uri=${encodeURIComponent(window.location.origin + "/auth/callback")}&scope=openid+profile+email&state=lux`}>
|
||||
Lux ID
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => window.location.href = `${process.env.NEXT_PUBLIC_IAM_URL || "https://iam.hanzo.ai"}/oauth2/authorize?response_type=code&client_id=${process.env.NEXT_PUBLIC_IAM_CLIENT_ID || "bootnode-platform"}&redirect_uri=${encodeURIComponent(window.location.origin + "/auth/callback")}&scope=openid+profile+email&state=pars`}>
|
||||
Pars ID
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Development mode: Show email/password form
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-gradient-to-b from-background to-muted/20 px-4">
|
||||
<Link href="/" className="mb-8 flex items-center space-x-2">
|
||||
<Blocks className="h-8 w-8" />
|
||||
<span className="text-2xl font-bold">Bootnode</span>
|
||||
</Link>
|
||||
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-xl">
|
||||
{mode === "login" ? "Sign in" : "Create account"}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{mode === "login"
|
||||
? "Enter your credentials to access the dashboard"
|
||||
: "Create a new account to get started"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs value={mode} onValueChange={(v) => setMode(v as "login" | "register")}>
|
||||
<TabsList className="grid w-full grid-cols-2 mb-4">
|
||||
<TabsTrigger value="login">Sign In</TabsTrigger>
|
||||
<TabsTrigger value="register">Register</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === "register" && (
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="name" className="text-sm font-medium">
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
placeholder="Your name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
autoComplete="name"
|
||||
required={mode === "register"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="email" className="text-sm font-medium">
|
||||
Email
|
||||
</label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="[email protected]"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
autoComplete="email"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="password" className="text-sm font-medium">
|
||||
Password
|
||||
</label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete={mode === "login" ? "current-password" : "new-password"}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{mode === "login" ? "Signing in..." : "Creating account..."}
|
||||
</>
|
||||
) : (
|
||||
mode === "login" ? "Sign in" : "Create account"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</Tabs>
|
||||
|
||||
<p className="mt-6 text-center text-xs text-muted-foreground">
|
||||
Development mode - local authentication
|
||||
</p>
|
||||
|
||||
{/* Demo credentials for localhost */}
|
||||
<div className="mt-3 rounded-md border border-dashed border-muted-foreground/25 bg-muted/50 p-3">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2">Demo Credentials</p>
|
||||
<div className="space-y-1 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Email:</span>
|
||||
<code
|
||||
className="font-mono bg-background px-1 rounded cursor-pointer hover:bg-primary/10"
|
||||
onClick={() => setEmail("[email protected]")}
|
||||
title="Click to use"
|
||||
>
|
||||
test@bootnode.dev
|
||||
</code>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Password:</span>
|
||||
<code
|
||||
className="font-mono bg-background px-1 rounded cursor-pointer hover:bg-primary/10"
|
||||
onClick={() => setPassword("testpass123")}
|
||||
title="Click to use"
|
||||
>
|
||||
testpass123
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground mt-2">
|
||||
Click values to autofill. Or register a new account.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LoginFallback() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense fallback={<LoginFallback />}>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
import Link from "next/link"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Navbar } from "@/components/navbar"
|
||||
import { Footer } from "@/components/footer"
|
||||
import { ChainGrid } from "@/components/chain-grid"
|
||||
import { CodeDemo } from "@/components/code-demo"
|
||||
import {
|
||||
ArrowRight,
|
||||
Blocks,
|
||||
Code2,
|
||||
Database,
|
||||
Globe,
|
||||
Layers,
|
||||
Shield,
|
||||
Wallet,
|
||||
Webhook,
|
||||
Zap,
|
||||
} from "lucide-react"
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<Navbar />
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="relative overflow-hidden border-b bg-gradient-to-b from-background to-muted/20">
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,#4f4f4f2e_1px,transparent_1px),linear-gradient(to_bottom,#4f4f4f2e_1px,transparent_1px)] bg-[size:14px_24px] [mask-image:radial-gradient(ellipse_60%_50%_at_50%_0%,#000_70%,transparent_100%)]" />
|
||||
<div className="container relative py-24 md:py-32 lg:py-40">
|
||||
<div className="mx-auto flex max-w-4xl flex-col items-center text-center">
|
||||
<Badge variant="secondary" className="mb-4">
|
||||
<Zap className="mr-1 h-3 w-3" />
|
||||
100+ Chains Supported
|
||||
</Badge>
|
||||
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl md:text-6xl lg:text-7xl">
|
||||
Build anything{" "}
|
||||
<span className="bg-gradient-to-r from-blue-500 to-cyan-500 bg-clip-text text-transparent">
|
||||
onchain
|
||||
</span>
|
||||
</h1>
|
||||
<p className="mt-6 max-w-2xl text-lg text-muted-foreground md:text-xl">
|
||||
The complete blockchain development platform. Multi-chain RPC, Token
|
||||
APIs, NFT APIs, Smart Wallets, Account Abstraction, and more. Ship
|
||||
faster with Bootnode.
|
||||
</p>
|
||||
<div className="mt-10 flex flex-col gap-4 sm:flex-row">
|
||||
<Button size="xl" asChild>
|
||||
<Link href="/dashboard">
|
||||
Start Building
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button size="xl" variant="outline" asChild>
|
||||
<Link href="/docs">View Documentation</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Products Grid */}
|
||||
<section className="border-b py-24">
|
||||
<div className="container">
|
||||
<div className="mx-auto max-w-2xl text-center">
|
||||
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">
|
||||
Everything you need to build Web3
|
||||
</h2>
|
||||
<p className="mt-4 text-lg text-muted-foreground">
|
||||
From RPC endpoints to account abstraction, Bootnode provides the
|
||||
complete infrastructure stack for blockchain developers.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-16 grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<ProductCard
|
||||
icon={<Globe className="h-6 w-6" />}
|
||||
title="Node"
|
||||
description="Build and scale your app on the most powerful web3 development platform with 99.999% uptime."
|
||||
href="/products/node"
|
||||
/>
|
||||
<ProductCard
|
||||
icon={<Database className="h-6 w-6" />}
|
||||
title="Data"
|
||||
description="Access complete blockchain data through one unified API that grows with you. Tokens, NFTs, transfers."
|
||||
href="/products/data"
|
||||
/>
|
||||
<ProductCard
|
||||
icon={<Wallet className="h-6 w-6" />}
|
||||
title="Wallets"
|
||||
description="Onboard users with secure, easy-to-use smart wallets. No seed phrase or gas required."
|
||||
href="/products/wallets"
|
||||
/>
|
||||
<ProductCard
|
||||
icon={<Layers className="h-6 w-6" />}
|
||||
title="Rollups"
|
||||
description="Launch a custom rollup with native developer tools and scale to millions of users."
|
||||
href="/products/rollups"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Interactive Demo */}
|
||||
<section className="border-b bg-muted/30 py-24">
|
||||
<div className="container">
|
||||
<div className="grid gap-12 lg:grid-cols-2 lg:gap-16">
|
||||
<div className="flex flex-col justify-center">
|
||||
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">
|
||||
Query the blockchain instantly
|
||||
</h2>
|
||||
<p className="mt-4 text-lg text-muted-foreground">
|
||||
Get started in seconds with our JSON-RPC API. Access real-time
|
||||
blockchain data across 100+ networks with a single API key.
|
||||
</p>
|
||||
<div className="mt-8 space-y-4">
|
||||
<FeatureItem
|
||||
icon={<Zap className="h-5 w-5 text-yellow-500" />}
|
||||
title="Lightning Fast"
|
||||
description="Sub-100ms response times with global edge caching"
|
||||
/>
|
||||
<FeatureItem
|
||||
icon={<Shield className="h-5 w-5 text-green-500" />}
|
||||
title="99.999% Uptime"
|
||||
description="Enterprise-grade reliability with automatic failover"
|
||||
/>
|
||||
<FeatureItem
|
||||
icon={<Globe className="h-5 w-5 text-blue-500" />}
|
||||
title="100+ Chains"
|
||||
description="Ethereum, Solana, Base, Arbitrum, and 96 more"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<Button asChild>
|
||||
<Link href="/docs/quickstart">
|
||||
Get Started
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<CodeDemo />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Supported Chains */}
|
||||
<section className="border-b py-24">
|
||||
<div className="container">
|
||||
<div className="mx-auto max-w-2xl text-center">
|
||||
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">
|
||||
100+ chains, one API
|
||||
</h2>
|
||||
<p className="mt-4 text-lg text-muted-foreground">
|
||||
Access every major blockchain through a unified interface. From
|
||||
Ethereum to Solana, Bitcoin to Base—we've got you covered.
|
||||
</p>
|
||||
</div>
|
||||
<ChainGrid />
|
||||
<div className="mt-12 text-center">
|
||||
<Button variant="outline" size="lg" asChild>
|
||||
<Link href="/chains">
|
||||
View All Chains
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features/Guides Section */}
|
||||
<section className="border-b py-24">
|
||||
<div className="container">
|
||||
<div className="mx-auto max-w-2xl text-center">
|
||||
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">
|
||||
Guides to get started
|
||||
</h2>
|
||||
<p className="mt-4 text-lg text-muted-foreground">
|
||||
Learn how to integrate Bootnode into your project with our
|
||||
comprehensive guides and tutorials.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-16 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<GuideCard
|
||||
title="Blockchain Basics"
|
||||
description="Get started by learning how to connect your app to Ethereum using Bootnode's JSON-RPC API."
|
||||
href="/docs/quickstart"
|
||||
badge="Quickstart"
|
||||
/>
|
||||
<GuideCard
|
||||
title="Smart Wallets"
|
||||
description="Create a Next.js app with embedded smart wallets, social login, and gas-less transactions."
|
||||
href="/docs/wallets/quickstart"
|
||||
badge="Tutorial"
|
||||
/>
|
||||
<GuideCard
|
||||
title="Real-time Webhooks"
|
||||
description="Receive fast and reliable HTTP POST requests for onchain events across 100+ chains."
|
||||
href="/docs/webhooks/quickstart"
|
||||
badge="Quickstart"
|
||||
/>
|
||||
<GuideCard
|
||||
title="Authentication"
|
||||
description="Add authentication and embedded smart wallets to your existing React project."
|
||||
href="/docs/auth/quickstart"
|
||||
badge="Tutorial"
|
||||
/>
|
||||
<GuideCard
|
||||
title="WebSocket Subscriptions"
|
||||
description="Subscribe to pending transactions, log events, new blocks and more using WebSockets."
|
||||
href="/docs/websockets/quickstart"
|
||||
badge="Quickstart"
|
||||
/>
|
||||
<GuideCard
|
||||
title="Account Abstraction"
|
||||
description="Enable existing EOAs to benefit from batching actions, sponsoring transactions, and more."
|
||||
href="/docs/aa/quickstart"
|
||||
badge="Guide"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* API Products */}
|
||||
<section className="border-b bg-muted/30 py-24">
|
||||
<div className="container">
|
||||
<div className="mx-auto max-w-2xl text-center">
|
||||
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">
|
||||
Powerful APIs for every use case
|
||||
</h2>
|
||||
<p className="mt-4 text-lg text-muted-foreground">
|
||||
From basic RPC calls to complex account abstraction flows, our APIs
|
||||
handle the heavy lifting so you can focus on building.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-16 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<APICard
|
||||
icon={<Globe className="h-6 w-6" />}
|
||||
title="RPC API"
|
||||
description="JSON-RPC proxy with load balancing, caching, and automatic failover across 100+ chains."
|
||||
features={["Multi-chain support", "MEV protection", "Archive nodes"]}
|
||||
/>
|
||||
<APICard
|
||||
icon={<Database className="h-6 w-6" />}
|
||||
title="Token API"
|
||||
description="Get ERC-20 token balances, metadata, prices, and transfer history in a single call."
|
||||
features={["Real-time prices", "Historical data", "Batch queries"]}
|
||||
/>
|
||||
<APICard
|
||||
icon={<Blocks className="h-6 w-6" />}
|
||||
title="NFT API"
|
||||
description="Fetch NFT metadata, ownership, collections, and marketplace data across all chains."
|
||||
features={["Media resolution", "Rarity data", "Floor prices"]}
|
||||
/>
|
||||
<APICard
|
||||
icon={<Code2 className="h-6 w-6" />}
|
||||
title="Transfers API"
|
||||
description="Query transaction history and token transfers with powerful filtering options."
|
||||
features={["Asset transfers", "Internal txs", "Cross-chain"]}
|
||||
/>
|
||||
<APICard
|
||||
icon={<Webhook className="h-6 w-6" />}
|
||||
title="Webhooks"
|
||||
description="Get real-time notifications for onchain events without polling. HMAC signed."
|
||||
features={["Address activity", "NFT transfers", "Custom filters"]}
|
||||
/>
|
||||
<APICard
|
||||
icon={<Wallet className="h-6 w-6" />}
|
||||
title="Smart Wallets"
|
||||
description="ERC-4337 account abstraction with gas sponsorship, batched transactions, and social login."
|
||||
features={["Gasless txs", "Session keys", "Social recovery"]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="py-24">
|
||||
<div className="container">
|
||||
<div className="relative overflow-hidden rounded-3xl bg-gradient-to-r from-blue-600 to-cyan-600 px-6 py-20 sm:px-12 sm:py-28">
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,#ffffff1a_1px,transparent_1px),linear-gradient(to_bottom,#ffffff1a_1px,transparent_1px)] bg-[size:14px_24px]" />
|
||||
<div className="relative mx-auto max-w-2xl text-center">
|
||||
<h2 className="text-3xl font-bold tracking-tight text-white sm:text-4xl">
|
||||
Ready to build the future?
|
||||
</h2>
|
||||
<p className="mt-4 text-lg text-white/80">
|
||||
Join thousands of developers building on Bootnode. Get started for
|
||||
free with 100M compute units per month.
|
||||
</p>
|
||||
<div className="mt-10 flex flex-col justify-center gap-4 sm:flex-row">
|
||||
<Button
|
||||
size="xl"
|
||||
className="bg-white text-blue-600 hover:bg-white/90"
|
||||
asChild
|
||||
>
|
||||
<Link href="/dashboard">
|
||||
Start Building Free
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
size="xl"
|
||||
variant="outline"
|
||||
className="border-white/30 bg-transparent text-white hover:bg-white/10"
|
||||
asChild
|
||||
>
|
||||
<Link href="/contact">Contact Sales</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProductCard({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
href,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
title: string
|
||||
description: string
|
||||
href: string
|
||||
}) {
|
||||
return (
|
||||
<Link href={href}>
|
||||
<Card className="group h-full transition-colors hover:border-foreground/20">
|
||||
<CardHeader>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10 text-primary transition-colors group-hover:bg-primary group-hover:text-primary-foreground">
|
||||
{icon}
|
||||
</div>
|
||||
<CardTitle className="mt-4">{title}</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<span className="inline-flex items-center text-sm font-medium text-primary">
|
||||
Learn more
|
||||
<ArrowRight className="ml-1 h-4 w-4" />
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function FeatureItem({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
title: string
|
||||
description: string
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">{title}</h3>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GuideCard({
|
||||
title,
|
||||
description,
|
||||
href,
|
||||
badge,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
href: string
|
||||
badge: string
|
||||
}) {
|
||||
return (
|
||||
<Link href={href}>
|
||||
<Card className="group h-full transition-colors hover:border-foreground/20">
|
||||
<CardHeader>
|
||||
<Badge variant="secondary" className="w-fit">
|
||||
{badge}
|
||||
</Badge>
|
||||
<CardTitle className="mt-2">{title}</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<span className="inline-flex items-center text-sm font-medium text-primary">
|
||||
Get started
|
||||
<ArrowRight className="ml-1 h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function APICard({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
features,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
title: string
|
||||
description: string
|
||||
features: string[]
|
||||
}) {
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
{icon}
|
||||
</div>
|
||||
<CardTitle className="mt-4">{title}</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2">
|
||||
{features.map((feature) => (
|
||||
<li
|
||||
key={feature}
|
||||
className="flex items-center text-sm text-muted-foreground"
|
||||
>
|
||||
<div className="mr-2 h-1.5 w-1.5 rounded-full bg-primary" />
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import Link from "next/link"
|
||||
import type { Metadata } from "next"
|
||||
import { Navbar } from "@/components/navbar"
|
||||
import { Footer } from "@/components/footer"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
ArrowRight,
|
||||
Check,
|
||||
HelpCircle,
|
||||
Zap,
|
||||
} from "lucide-react"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Pricing",
|
||||
description:
|
||||
"Simple, transparent pricing for blockchain infrastructure. Start free with 100M compute units per month.",
|
||||
}
|
||||
|
||||
const tiers = [
|
||||
{
|
||||
name: "Free",
|
||||
price: "$0",
|
||||
period: "forever",
|
||||
description: "For hobbyists and developers exploring blockchain. Get started with no commitment.",
|
||||
cta: "Start Building Free",
|
||||
ctaHref: "/dashboard",
|
||||
ctaVariant: "outline" as const,
|
||||
highlighted: false,
|
||||
limits: [
|
||||
"100M compute units / month",
|
||||
"5 API keys",
|
||||
"3 chains",
|
||||
"Community support",
|
||||
"Standard rate limits",
|
||||
"7-day data retention",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Growth",
|
||||
price: "$99",
|
||||
period: "/month",
|
||||
description: "For teams shipping production apps. Everything you need to scale with confidence.",
|
||||
cta: "Get Started",
|
||||
ctaHref: "/dashboard",
|
||||
ctaVariant: "default" as const,
|
||||
highlighted: true,
|
||||
limits: [
|
||||
"1B compute units / month",
|
||||
"Unlimited API keys",
|
||||
"All 100+ chains",
|
||||
"Email support (< 24h response)",
|
||||
"Enhanced rate limits",
|
||||
"30-day data retention",
|
||||
"Webhook delivery guarantees",
|
||||
"Team members (up to 10)",
|
||||
"Usage analytics dashboard",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Enterprise",
|
||||
price: "Custom",
|
||||
period: "",
|
||||
description: "For organizations with mission-critical infrastructure and compliance requirements.",
|
||||
cta: "Contact Sales",
|
||||
ctaHref: "/contact",
|
||||
ctaVariant: "outline" as const,
|
||||
highlighted: false,
|
||||
limits: [
|
||||
"Custom compute units",
|
||||
"Dedicated infrastructure",
|
||||
"99.999% uptime SLA",
|
||||
"Custom chain support",
|
||||
"24/7 priority support",
|
||||
"90-day data retention",
|
||||
"Dedicated account manager",
|
||||
"Custom rate limits",
|
||||
"SOC 2 compliance reports",
|
||||
"SSO / SAML authentication",
|
||||
"Unlimited team members",
|
||||
"Private networking (VPC peering)",
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const faqs = [
|
||||
{
|
||||
question: "What is a compute unit?",
|
||||
answer:
|
||||
"A compute unit (CU) is Bootnode's universal unit of measurement for API usage. Different API methods consume different amounts of CUs based on their computational complexity. For example, a simple eth_blockNumber call costs 1 CU, while eth_getLogs may cost 10-100 CUs depending on the range. This ensures fair pricing across all chains and methods.",
|
||||
},
|
||||
{
|
||||
question: "Can I switch plans at any time?",
|
||||
answer:
|
||||
"Yes. You can upgrade or downgrade your plan at any time from your dashboard. When upgrading, the new plan takes effect immediately and you are billed a prorated amount for the remainder of the billing cycle. When downgrading, the change takes effect at the start of the next billing cycle.",
|
||||
},
|
||||
{
|
||||
question: "What happens if I exceed my compute unit limit?",
|
||||
answer:
|
||||
"On the Free plan, requests are rate-limited once you reach your monthly allocation. On the Growth plan, you can enable overage billing at $0.10 per million additional CUs so your app never goes down. Enterprise plans include custom overage arrangements.",
|
||||
},
|
||||
{
|
||||
question: "Do you offer annual billing?",
|
||||
answer:
|
||||
"Yes. Annual billing is available for the Growth plan at $79/month (a 20% discount). Enterprise plans are custom-quoted with flexible billing terms. Contact our sales team for details.",
|
||||
},
|
||||
{
|
||||
question: "Which chains are available on each plan?",
|
||||
answer:
|
||||
"The Free plan gives you access to any 3 chains of your choice. The Growth and Enterprise plans include all 100+ supported chains with no restrictions. You can change your selected chains on the Free plan at any time.",
|
||||
},
|
||||
{
|
||||
question: "What kind of support do you offer?",
|
||||
answer:
|
||||
"The Free plan includes access to our community Discord and documentation. The Growth plan includes email support with a guaranteed response time of under 24 hours during business hours. Enterprise plans include 24/7 priority support with a dedicated account manager and Slack channel.",
|
||||
},
|
||||
{
|
||||
question: "Is there a free trial for the Growth plan?",
|
||||
answer:
|
||||
"The Free plan serves as a generous trial with 100M compute units per month. There is no time limit -- you can use the Free plan indefinitely. When you are ready to scale, upgrading to Growth takes one click.",
|
||||
},
|
||||
{
|
||||
question: "Do you offer discounts for startups?",
|
||||
answer:
|
||||
"Yes. We offer a startup program that provides Growth plan access at no cost for 6 months for qualifying early-stage startups. Apply through our startup program page or contact sales for details.",
|
||||
},
|
||||
]
|
||||
|
||||
export default function PricingPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<Navbar />
|
||||
|
||||
{/* Hero */}
|
||||
<section className="border-b bg-gradient-to-b from-background to-muted/20">
|
||||
<div className="container py-20 md:py-28">
|
||||
<div className="mx-auto max-w-3xl text-center">
|
||||
<Badge variant="secondary" className="mb-4">
|
||||
<Zap className="mr-1 h-3 w-3" />
|
||||
Simple Pricing
|
||||
</Badge>
|
||||
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl">
|
||||
Start free, scale without limits
|
||||
</h1>
|
||||
<p className="mt-6 text-lg text-muted-foreground">
|
||||
Transparent pricing with no hidden fees. Every plan includes access to
|
||||
our full API suite: RPC, Token API, NFT API, Webhooks, Smart Wallets, and more.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Pricing Cards */}
|
||||
<section className="py-16">
|
||||
<div className="container">
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
{tiers.map((tier) => (
|
||||
<Card
|
||||
key={tier.name}
|
||||
className={
|
||||
tier.highlighted
|
||||
? "relative border-primary shadow-lg"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{tier.highlighted && (
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
||||
<Badge>Most Popular</Badge>
|
||||
</div>
|
||||
)}
|
||||
<CardHeader>
|
||||
<CardTitle>{tier.name}</CardTitle>
|
||||
<div className="mt-4">
|
||||
<span className="text-4xl font-bold">{tier.price}</span>
|
||||
{tier.period && (
|
||||
<span className="text-muted-foreground">{tier.period}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{tier.description}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button
|
||||
className="w-full"
|
||||
variant={tier.ctaVariant}
|
||||
asChild
|
||||
>
|
||||
<Link href={tier.ctaHref}>
|
||||
{tier.cta}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Separator className="my-6" />
|
||||
<ul className="space-y-3">
|
||||
{tier.limits.map((limit) => (
|
||||
<li key={limit} className="flex items-start gap-2">
|
||||
<Check className="mt-0.5 h-4 w-4 shrink-0 text-green-500" />
|
||||
<span className="text-sm">{limit}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Compute Unit Table */}
|
||||
<section className="border-y bg-muted/30 py-16">
|
||||
<div className="container">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<h2 className="text-2xl font-bold tracking-tight text-center">
|
||||
Compute Unit Costs
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-muted-foreground">
|
||||
Each API method uses a specific number of compute units. Here are some common examples.
|
||||
</p>
|
||||
<div className="mt-8 overflow-hidden rounded-lg border bg-card">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">Method</th>
|
||||
<th className="px-4 py-3 text-right text-sm font-medium">CUs</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{[
|
||||
{ method: "eth_blockNumber", cus: "1" },
|
||||
{ method: "eth_getBalance", cus: "5" },
|
||||
{ method: "eth_call", cus: "10" },
|
||||
{ method: "eth_getTransactionReceipt", cus: "10" },
|
||||
{ method: "eth_getLogs (100 blocks)", cus: "25" },
|
||||
{ method: "eth_getBlockByNumber (full)", cus: "50" },
|
||||
{ method: "debug_traceTransaction", cus: "200" },
|
||||
{ method: "Token API - Get balances", cus: "5" },
|
||||
{ method: "NFT API - Get collection", cus: "10" },
|
||||
{ method: "Webhook - Create", cus: "50" },
|
||||
].map((row) => (
|
||||
<tr key={row.method} className="hover:bg-muted/30">
|
||||
<td className="px-4 py-3 font-mono text-sm">{row.method}</td>
|
||||
<td className="px-4 py-3 text-right text-sm">{row.cus}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* FAQ */}
|
||||
<section className="py-16">
|
||||
<div className="container">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<div className="text-center">
|
||||
<HelpCircle className="mx-auto mb-4 h-8 w-8 text-primary" />
|
||||
<h2 className="text-2xl font-bold tracking-tight">
|
||||
Frequently Asked Questions
|
||||
</h2>
|
||||
</div>
|
||||
<div className="mt-12 space-y-6">
|
||||
{faqs.map((faq) => (
|
||||
<div key={faq.question} className="rounded-lg border bg-card p-6">
|
||||
<h3 className="font-semibold">{faq.question}</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground leading-relaxed">
|
||||
{faq.answer}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section className="border-t bg-muted/30 py-16">
|
||||
<div className="container">
|
||||
<div className="mx-auto max-w-2xl text-center">
|
||||
<h2 className="text-3xl font-bold tracking-tight">
|
||||
Ready to start building?
|
||||
</h2>
|
||||
<p className="mt-4 text-lg text-muted-foreground">
|
||||
Get 100M compute units free every month. No credit card required.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-col justify-center gap-4 sm:flex-row">
|
||||
<Button size="lg" asChild>
|
||||
<Link href="/dashboard">
|
||||
Start Building Free
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button size="lg" variant="outline" asChild>
|
||||
<Link href="/contact">Contact Sales</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import Link from "next/link"
|
||||
import type { Metadata } from "next"
|
||||
import { Navbar } from "@/components/navbar"
|
||||
import { Footer } from "@/components/footer"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Shield } from "lucide-react"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Privacy Policy",
|
||||
description:
|
||||
"Bootnode Privacy Policy. Learn how we collect, use, and protect your personal information.",
|
||||
}
|
||||
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<Navbar />
|
||||
|
||||
{/* Hero */}
|
||||
<section className="border-b bg-gradient-to-b from-background to-muted/20">
|
||||
<div className="container py-16 md:py-20">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<Badge variant="secondary" className="mb-4">
|
||||
<Shield className="mr-1 h-3 w-3" />
|
||||
Legal
|
||||
</Badge>
|
||||
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl">
|
||||
Privacy Policy
|
||||
</h1>
|
||||
<p className="mt-4 text-muted-foreground">
|
||||
Effective date: January 1, 2026
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Content */}
|
||||
<section className="py-16">
|
||||
<div className="container">
|
||||
<div className="prose prose-invert mx-auto max-w-3xl">
|
||||
<div className="space-y-8 text-sm text-muted-foreground leading-relaxed">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-foreground">1. Introduction</h2>
|
||||
<p className="mt-3">
|
||||
Hanzo AI, Inc. ("Bootnode," "we," "us," or "our") operates the
|
||||
bootnode.dev website and the Bootnode API platform (collectively, the
|
||||
"Service"). This Privacy Policy explains how we collect, use, disclose,
|
||||
and safeguard your information when you use our Service.
|
||||
</p>
|
||||
<p className="mt-3">
|
||||
By accessing or using the Service, you agree to this Privacy Policy. If
|
||||
you do not agree with the terms of this policy, please do not access the
|
||||
Service.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-foreground">2. Information We Collect</h2>
|
||||
|
||||
<h3 className="mt-4 text-base font-semibold text-foreground">2.1 Information You Provide</h3>
|
||||
<p className="mt-2">We collect information you voluntarily provide when you:</p>
|
||||
<ul className="mt-2 ml-4 list-disc space-y-1">
|
||||
<li>Create an account (email address, name, password)</li>
|
||||
<li>Subscribe to a paid plan (billing address, payment information via our payment processor Stripe)</li>
|
||||
<li>Contact us via email or our contact form (name, email, message content)</li>
|
||||
<li>Participate in surveys or promotions</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="mt-4 text-base font-semibold text-foreground">2.2 Information Collected Automatically</h3>
|
||||
<p className="mt-2">When you use the Service, we automatically collect:</p>
|
||||
<ul className="mt-2 ml-4 list-disc space-y-1">
|
||||
<li>API usage data (request counts, methods called, response times, error rates)</li>
|
||||
<li>IP addresses used to make API requests</li>
|
||||
<li>Device and browser information when using the dashboard</li>
|
||||
<li>Cookies and similar tracking technologies for authentication and analytics</li>
|
||||
<li>Log data (timestamps, request paths, HTTP status codes)</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="mt-4 text-base font-semibold text-foreground">2.3 Information We Do Not Collect</h3>
|
||||
<p className="mt-2">
|
||||
We do not inspect, store, or log the content of your API request bodies or
|
||||
response bodies beyond what is necessary for rate limiting and abuse
|
||||
prevention. We do not collect or store private keys, seed phrases, or
|
||||
wallet passwords.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-foreground">3. How We Use Your Information</h2>
|
||||
<p className="mt-3">We use collected information to:</p>
|
||||
<ul className="mt-2 ml-4 list-disc space-y-1">
|
||||
<li>Provide, operate, and maintain the Service</li>
|
||||
<li>Process transactions and send related billing information</li>
|
||||
<li>Monitor API usage for rate limiting, abuse prevention, and capacity planning</li>
|
||||
<li>Send service-related notices, including downtime alerts and security notifications</li>
|
||||
<li>Respond to your comments, questions, and support requests</li>
|
||||
<li>Analyze usage patterns to improve the Service</li>
|
||||
<li>Detect, prevent, and address technical issues, fraud, and security breaches</li>
|
||||
<li>Comply with legal obligations</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-foreground">4. How We Share Your Information</h2>
|
||||
<p className="mt-3">We do not sell your personal information. We may share information with:</p>
|
||||
<ul className="mt-2 ml-4 list-disc space-y-1">
|
||||
<li>
|
||||
<strong className="text-foreground">Service providers:</strong> Third-party vendors who assist
|
||||
in operating the Service (e.g., Stripe for payments, cloud infrastructure
|
||||
providers for hosting, analytics providers). These vendors are
|
||||
contractually obligated to protect your information.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">Legal requirements:</strong> When required by law, subpoena,
|
||||
or other legal process, or when we believe disclosure is necessary to
|
||||
protect our rights, your safety, or the safety of others.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">Business transfers:</strong> In connection with a merger,
|
||||
acquisition, or sale of assets, your information may be transferred as
|
||||
part of the transaction.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">With your consent:</strong> We may share information for any
|
||||
other purpose with your explicit consent.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-foreground">5. Data Retention</h2>
|
||||
<p className="mt-3">
|
||||
We retain your account information for as long as your account is active or
|
||||
as needed to provide the Service. API usage logs are retained for 30 days
|
||||
(Growth plan) or 90 days (Enterprise plan). After account deletion, we
|
||||
retain minimal data as required by law or legitimate business purposes
|
||||
(e.g., fraud prevention) for up to 12 months, after which it is permanently
|
||||
deleted.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-foreground">6. Data Security</h2>
|
||||
<p className="mt-3">
|
||||
We implement industry-standard security measures to protect your
|
||||
information, including encryption in transit (TLS 1.3), encryption at rest
|
||||
(AES-256), API key hashing (bcrypt), and strict access controls. However,
|
||||
no method of transmission over the Internet is 100% secure. See our{" "}
|
||||
<Link href="/security" className="text-primary hover:underline">
|
||||
Security page
|
||||
</Link>{" "}
|
||||
for detailed information about our security practices.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-foreground">7. Your Rights</h2>
|
||||
<p className="mt-3">Depending on your location, you may have the right to:</p>
|
||||
<ul className="mt-2 ml-4 list-disc space-y-1">
|
||||
<li>Access the personal data we hold about you</li>
|
||||
<li>Correct inaccurate personal data</li>
|
||||
<li>Delete your personal data</li>
|
||||
<li>Export your data in a portable format</li>
|
||||
<li>Restrict or object to processing of your personal data</li>
|
||||
<li>Withdraw consent at any time (where processing is based on consent)</li>
|
||||
</ul>
|
||||
<p className="mt-3">
|
||||
To exercise these rights, contact us at{" "}
|
||||
<a
|
||||
href="mailto:[email protected]"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
privacy@bootnode.dev
|
||||
</a>
|
||||
. We will respond within 30 days.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-foreground">8. Cookies</h2>
|
||||
<p className="mt-3">
|
||||
We use essential cookies for authentication and session management. We
|
||||
use analytics cookies (which can be opted out of) to understand how the
|
||||
Service is used. We do not use advertising cookies or sell cookie data to
|
||||
third parties. You can control cookie settings through your browser
|
||||
preferences.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-foreground">9. International Data Transfers</h2>
|
||||
<p className="mt-3">
|
||||
Your information may be transferred to and processed in countries other than
|
||||
your own. We ensure appropriate safeguards are in place, including Standard
|
||||
Contractual Clauses approved by the European Commission, for any transfers
|
||||
of personal data outside the European Economic Area.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-foreground">10. Children's Privacy</h2>
|
||||
<p className="mt-3">
|
||||
The Service is not intended for individuals under 16 years of age. We do not
|
||||
knowingly collect personal information from children under 16. If you become
|
||||
aware that a child has provided us with personal information, please contact
|
||||
us and we will take steps to delete such information.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-foreground">11. Changes to This Policy</h2>
|
||||
<p className="mt-3">
|
||||
We may update this Privacy Policy from time to time. We will notify you of
|
||||
material changes by posting the new policy on this page and updating the
|
||||
"Effective date" above. For significant changes, we will provide additional
|
||||
notice via email or a banner on the Service. Your continued use of the
|
||||
Service after changes become effective constitutes acceptance of the revised
|
||||
policy.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-foreground">12. Contact Us</h2>
|
||||
<p className="mt-3">
|
||||
If you have questions about this Privacy Policy or our data practices,
|
||||
please contact us at:
|
||||
</p>
|
||||
<div className="mt-3 rounded-lg border bg-card p-4">
|
||||
<p className="font-medium text-foreground">Hanzo AI, Inc.</p>
|
||||
<p className="mt-1">Email:{" "}
|
||||
<a
|
||||
href="mailto:[email protected]"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
privacy@bootnode.dev
|
||||
</a>
|
||||
</p>
|
||||
<p>San Francisco, California, United States</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
import Link from "next/link"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Navbar } from "@/components/navbar"
|
||||
import { Footer } from "@/components/footer"
|
||||
import {
|
||||
ArrowRight,
|
||||
Blocks,
|
||||
CheckCircle2,
|
||||
Coins,
|
||||
Database,
|
||||
FileSearch,
|
||||
Filter,
|
||||
Globe,
|
||||
Image,
|
||||
Layers,
|
||||
Search,
|
||||
Zap,
|
||||
} from "lucide-react"
|
||||
|
||||
export default function DataProductPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<Navbar />
|
||||
|
||||
{/* Hero */}
|
||||
<section className="relative overflow-hidden border-b bg-gradient-to-b from-background to-muted/20">
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,#4f4f4f2e_1px,transparent_1px),linear-gradient(to_bottom,#4f4f4f2e_1px,transparent_1px)] bg-[size:14px_24px] [mask-image:radial-gradient(ellipse_60%_50%_at_50%_0%,#000_70%,transparent_100%)]" />
|
||||
<div className="container relative py-24 md:py-32 lg:py-40">
|
||||
<div className="mx-auto flex max-w-4xl flex-col items-center text-center">
|
||||
<Badge variant="secondary" className="mb-4">
|
||||
<Database className="mr-1 h-3 w-3" />
|
||||
Unified Data APIs
|
||||
</Badge>
|
||||
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl md:text-6xl lg:text-7xl">
|
||||
All blockchain data,{" "}
|
||||
<span className="bg-gradient-to-r from-violet-500 to-fuchsia-500 bg-clip-text text-transparent">
|
||||
one API call
|
||||
</span>
|
||||
</h1>
|
||||
<p className="mt-6 max-w-2xl text-lg text-muted-foreground md:text-xl">
|
||||
Token balances, NFT metadata, and transfer history across 100+
|
||||
chains. No indexing, no subgraphs, no infrastructure to
|
||||
manage. Query any onchain data with a single REST call.
|
||||
</p>
|
||||
<div className="mt-10 flex flex-col gap-4 sm:flex-row">
|
||||
<Button size="xl" asChild>
|
||||
<Link href="/dashboard">
|
||||
Start Building
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button size="xl" variant="outline" asChild>
|
||||
<Link href="/docs/data">API Reference</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Three API Tabs */}
|
||||
<section className="border-b py-24">
|
||||
<div className="container">
|
||||
<div className="mx-auto max-w-2xl text-center">
|
||||
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">
|
||||
Three APIs, complete coverage
|
||||
</h2>
|
||||
<p className="mt-4 text-lg text-muted-foreground">
|
||||
Bootnode Data APIs handle the complex indexing, caching, and
|
||||
normalization so you never have to decode raw logs or parse
|
||||
transaction receipts again.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Token API */}
|
||||
<div className="mt-20">
|
||||
<div className="grid gap-12 lg:grid-cols-2 lg:gap-16">
|
||||
<div className="flex flex-col justify-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-amber-500/10 text-amber-500">
|
||||
<Coins className="h-5 w-5" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold">Token API</h3>
|
||||
</div>
|
||||
<p className="mt-4 text-muted-foreground">
|
||||
Retrieve ERC-20 and native token balances, metadata, historical
|
||||
prices, and allowances for any address on any supported chain.
|
||||
A single call returns everything your portfolio view needs.
|
||||
</p>
|
||||
<ul className="mt-6 space-y-3">
|
||||
{[
|
||||
"Balances for all tokens owned by an address in one call",
|
||||
"Real-time and historical USD prices with 1-minute granularity",
|
||||
"Full token metadata: name, symbol, decimals, logo URI",
|
||||
"ERC-20 allowance lookups for spender contracts",
|
||||
"Cross-chain balances aggregated in a single response",
|
||||
].map((item) => (
|
||||
<li key={item} className="flex items-start gap-3 text-sm">
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-green-500" />
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<CodeBlock
|
||||
title="GET /v1/tokens/balances"
|
||||
code={`curl "https://api.bootnode.dev/v1/tokens/balances?\\
|
||||
address=0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045&\\
|
||||
chains=ethereum,base,arbitrum" \\
|
||||
-H "X-API-Key: YOUR_API_KEY"
|
||||
|
||||
{
|
||||
"address": "0xd8dA6BF2...",
|
||||
"balances": [
|
||||
{
|
||||
"chain": "ethereum",
|
||||
"token": "0xA0b8...ec7",
|
||||
"symbol": "USDC",
|
||||
"name": "USD Coin",
|
||||
"decimals": 6,
|
||||
"balance": "125430000000",
|
||||
"balanceFormatted": "125430.0",
|
||||
"priceUsd": 1.0001,
|
||||
"valueUsd": 125442.53
|
||||
},
|
||||
{
|
||||
"chain": "ethereum",
|
||||
"token": "native",
|
||||
"symbol": "ETH",
|
||||
"decimals": 18,
|
||||
"balance": "4200000000000000000",
|
||||
"balanceFormatted": "4.2",
|
||||
"priceUsd": 3245.67,
|
||||
"valueUsd": 13631.81
|
||||
}
|
||||
],
|
||||
"totalValueUsd": 139074.34
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* NFT API */}
|
||||
<div className="mt-24">
|
||||
<div className="grid gap-12 lg:grid-cols-2 lg:gap-16">
|
||||
<div className="order-2 lg:order-1">
|
||||
<CodeBlock
|
||||
title="GET /v1/nfts/owned"
|
||||
code={`curl "https://api.bootnode.dev/v1/nfts/owned?\\
|
||||
address=0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045&\\
|
||||
chain=ethereum&limit=2" \\
|
||||
-H "X-API-Key: YOUR_API_KEY"
|
||||
|
||||
{
|
||||
"address": "0xd8dA6BF2...",
|
||||
"chain": "ethereum",
|
||||
"nfts": [
|
||||
{
|
||||
"contract": "0xBC4CA0...a426f2",
|
||||
"tokenId": "8520",
|
||||
"standard": "ERC-721",
|
||||
"collection": "Bored Ape Yacht Club",
|
||||
"name": "BAYC #8520",
|
||||
"image": "https://media.bootnode.dev/ipfs/Qm...",
|
||||
"attributes": [
|
||||
{ "trait_type": "Fur", "value": "Dark Brown" },
|
||||
{ "trait_type": "Eyes", "value": "Bored" }
|
||||
],
|
||||
"floorPrice": { "value": "12.4", "currency": "ETH" }
|
||||
}
|
||||
],
|
||||
"totalCount": 47,
|
||||
"cursor": "eyJsYXN0X2lk..."
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="order-1 flex flex-col justify-center lg:order-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-500/10 text-purple-500">
|
||||
<Image className="h-5 w-5" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold">NFT API</h3>
|
||||
</div>
|
||||
<p className="mt-4 text-muted-foreground">
|
||||
Fetch NFTs owned by any address, collection metadata,
|
||||
individual token details, and transfer history. Media files
|
||||
from IPFS and Arweave are resolved and cached through our CDN
|
||||
for instant loading.
|
||||
</p>
|
||||
<ul className="mt-6 space-y-3">
|
||||
{[
|
||||
"Owned NFTs with full metadata and resolved media",
|
||||
"IPFS and Arweave gateway with global CDN caching",
|
||||
"Collection-level data: floor price, supply, holders",
|
||||
"ERC-721 and ERC-1155 support with trait parsing",
|
||||
"Spam detection filters out known scam tokens",
|
||||
].map((item) => (
|
||||
<li key={item} className="flex items-start gap-3 text-sm">
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-green-500" />
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transfers API */}
|
||||
<div className="mt-24">
|
||||
<div className="grid gap-12 lg:grid-cols-2 lg:gap-16">
|
||||
<div className="flex flex-col justify-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-500/10 text-blue-500">
|
||||
<FileSearch className="h-5 w-5" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold">Transfers API</h3>
|
||||
</div>
|
||||
<p className="mt-4 text-muted-foreground">
|
||||
Query the complete transfer history for any address, contract,
|
||||
or token. Filter by direction, asset type, block range, or
|
||||
counterparty. Results include decoded event data and USD values
|
||||
at time of transfer.
|
||||
</p>
|
||||
<ul className="mt-6 space-y-3">
|
||||
{[
|
||||
"ERC-20, ERC-721, ERC-1155, and native transfers",
|
||||
"Internal (trace-level) transfers included",
|
||||
"Filter by from/to address, token, block range",
|
||||
"USD value at the time of each transfer",
|
||||
"Cursor-based pagination for large histories",
|
||||
].map((item) => (
|
||||
<li key={item} className="flex items-start gap-3 text-sm">
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-green-500" />
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<CodeBlock
|
||||
title="GET /v1/transfers"
|
||||
code={`curl "https://api.bootnode.dev/v1/transfers?\\
|
||||
address=0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045&\\
|
||||
chain=ethereum&category=erc20&limit=2" \\
|
||||
-H "X-API-Key: YOUR_API_KEY"
|
||||
|
||||
{
|
||||
"transfers": [
|
||||
{
|
||||
"hash": "0x3a1b...",
|
||||
"blockNumber": 19234567,
|
||||
"timestamp": "2025-01-15T14:23:01Z",
|
||||
"from": "0xd8dA6BF2...",
|
||||
"to": "0x7a250d56...",
|
||||
"category": "erc20",
|
||||
"token": {
|
||||
"address": "0xA0b8...ec7",
|
||||
"symbol": "USDC",
|
||||
"decimals": 6
|
||||
},
|
||||
"value": "50000000000",
|
||||
"valueFormatted": "50000.0",
|
||||
"valueUsd": 50005.0
|
||||
}
|
||||
],
|
||||
"cursor": "eyJibG9ja..."
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Why Bootnode Data */}
|
||||
<section className="border-b bg-muted/30 py-24">
|
||||
<div className="container">
|
||||
<div className="mx-auto max-w-2xl text-center">
|
||||
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">
|
||||
Why developers choose Bootnode Data
|
||||
</h2>
|
||||
</div>
|
||||
<div className="mt-16 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<FeatureCard
|
||||
icon={<Globe className="h-6 w-6" />}
|
||||
title="Cross-Chain by Default"
|
||||
description="Every endpoint accepts a chains parameter. Query Ethereum, Base, Arbitrum, Polygon, and 96 more networks with the same request format and API key."
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={<Zap className="h-6 w-6" />}
|
||||
title="Real-Time Indexing"
|
||||
description="Data is available within seconds of block confirmation. No waiting for subgraph sync or indexer catch-up. You always get the latest state."
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={<Search className="h-6 w-6" />}
|
||||
title="Rich Filtering"
|
||||
description="Filter by token address, block range, transfer direction, asset category, and more. Get exactly the data you need without client-side post-processing."
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={<Layers className="h-6 w-6" />}
|
||||
title="Normalized Responses"
|
||||
description="Consistent JSON schemas across all chains. Token decimals are always formatted, addresses are checksummed, and timestamps follow ISO 8601."
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={<Filter className="h-6 w-6" />}
|
||||
title="Spam Filtering"
|
||||
description="Known spam tokens and phishing NFTs are automatically filtered. Opt in or out of spam filtering per request with a single query parameter."
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={<Blocks className="h-6 w-6" />}
|
||||
title="Batch Support"
|
||||
description="Query multiple addresses or tokens in a single request. Reduce round trips by up to 100x when building portfolio views or dashboards."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section className="py-24">
|
||||
<div className="container">
|
||||
<div className="relative overflow-hidden rounded-3xl bg-gradient-to-r from-violet-600 to-fuchsia-600 px-6 py-20 sm:px-12 sm:py-28">
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,#ffffff1a_1px,transparent_1px),linear-gradient(to_bottom,#ffffff1a_1px,transparent_1px)] bg-[size:14px_24px]" />
|
||||
<div className="relative mx-auto max-w-2xl text-center">
|
||||
<h2 className="text-3xl font-bold tracking-tight text-white sm:text-4xl">
|
||||
Stop building infrastructure. Start building products.
|
||||
</h2>
|
||||
<p className="mt-4 text-lg text-white/80">
|
||||
Replace your indexing pipeline with a single API call. Free tier
|
||||
includes 100M compute units per month and access to all Data
|
||||
APIs.
|
||||
</p>
|
||||
<div className="mt-10 flex flex-col justify-center gap-4 sm:flex-row">
|
||||
<Button
|
||||
size="xl"
|
||||
className="bg-white text-violet-600 hover:bg-white/90"
|
||||
asChild
|
||||
>
|
||||
<Link href="/dashboard">
|
||||
Get Your API Key
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
size="xl"
|
||||
variant="outline"
|
||||
className="border-white/30 bg-transparent text-white hover:bg-white/10"
|
||||
asChild
|
||||
>
|
||||
<Link href="/docs/data">Explore the Docs</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FeatureCard({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
title: string
|
||||
description: string
|
||||
}) {
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
{icon}
|
||||
</div>
|
||||
<CardTitle className="mt-4">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function CodeBlock({ title, code }: { title: string; code: string }) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border bg-card">
|
||||
<div className="flex items-center border-b px-4 py-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
<pre className="overflow-x-auto p-4 text-sm leading-relaxed">
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user