# POPKins Local Devnet: OP Stack for Local Development
## Product Requirements Document
**Status:** Draft
**Last Updated:** December 2025
**Audience:** Developers wanting to run OP Stack locally without external dependencies
---
## 1. Product Summary
POPKins Local Devnet is a zero-dependency local OP Stack development environment. It enables developers to run a complete OP Stack chain on their local machine without connecting to Ethereum, Celestia, or POPSigner Cloud.
### What It Does
- Generates a ready-to-run local OP Stack environment
- Includes pre-deployed L1 contracts on Anvil (mock Ethereum)
- Includes mock Celestia DA layer with full op-alt-da integration
- Includes POPSigner Lite for local transaction signing
- Ships as a downloadable artifact bundle with `docker compose up` experience
### Why It Exists
| Problem | Solution |
|---------|----------|
| Real testnet deployments are slow and cost gas | Anvil is instant and free |
| Celestia DA requires running a light node | Mock Celestia is in-memory |
| POPSigner Cloud requires API keys and internet | POPSigner Lite works offline |
| Complex setup discourages new developers | One command: `docker compose up` |
### The Funnel
Local Devnet is the **entry point** to POPSigner's ecosystem:
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Local Devnet │ ──▶ │ Sepolia/Testnet│ ──▶ │ Mainnet │
│ (free, local) │ │ (real chains) │ │ (production) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
Same flags Same flags Same flags
Same binaries Same binaries Same binaries
Mock backends Real backends Real backends
```
Developers learn POPSigner's workflow locally, then seamlessly upgrade to real infrastructure.
---
## 2. Target User Persona
**Primary Persona:** Application developers building on OP Stack rollups.
This includes:
- Developers building L2 dApps
- Teams evaluating OP Stack + Celestia DA
- Engineers learning rollup architecture
- CI/CD pipelines needing ephemeral chains
**These users:**
- Want to iterate quickly without testnet delays
- Don't want to manage infrastructure or keys
- Prefer `docker compose up` simplicity
- Value offline/airgapped development capability
---
## 3. Design Principles
| # | Principle | Description |
|---|-----------|-------------|
| 1 | Zero external dependencies | Works completely offline |
| 2 | Same binaries as production | op-node, op-geth, op-batcher, op-proposer, op-alt-da are all real |
| 3 | State persistence | Developers can stop/resume their devnet |
| 4 | Instant feedback | Anvil automines, mock Celestia has no finality delay |
| 5 | One-command startup | `docker compose up` and you're running |
| 6 | Clear upgrade path | Same config patterns work on testnet/mainnet |
---
## 4. Architecture
### 4.1 Component Overview
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ LOCAL DEVNET │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────────┐ │
│ │ op-geth │◀───▶│ op-node │◀───▶│ op-alt-da │ │
│ │ (L2) │ │ │ │ (real) │ │
│ └─────────────┘ └──────┬──────┘ └──────────────┬──────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────┐ ┌─────────────────┐ │
│ │ Anvil │ │ mock-celestia │ │
│ │ (mock L1) │ │ (mock DA) │ │
│ └─────────────┘ └─────────────────┘ │
│ ▲ │
│ │ │
│ ┌─────────────┐ ┌──────┴──────┐ │
│ │ op-proposer │────▶│popsigner-lite│◀────┌─────────────┐ │
│ └─────────────┘ └─────────────┘ │ op-batcher │ │
│ └─────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
```
### 4.2 Component Details
| Component | Image | Purpose | Backend |
|-----------|-------|---------|---------|
| **Anvil** | `ghcr.io/foundry-rs/foundry` | Mock L1 Ethereum | In-memory + state dump |
| **mock-celestia** | `ghcr.io/bidon15/mock-celestia` | Mock Celestia DA layer | In-memory + state dump |
| **op-alt-da** | `ghcr.io/celestiaorg/op-alt-da` | Real DA server | → mock-celestia |
| **popsigner-lite** | `ghcr.io/bidon15/popsigner-lite` | Local signing service | Anvil's known keys |
| **op-geth** | `us-docker.pkg.dev/.../op-geth` | L2 execution layer | - |
| **op-node** | `us-docker.pkg.dev/.../op-node` | L2 derivation | → Anvil + op-alt-da |
| **op-batcher** | `us-docker.pkg.dev/.../op-batcher` | Batch submission | → Anvil + op-alt-da + popsigner-lite |
| **op-proposer** | `us-docker.pkg.dev/.../op-proposer` | State root submission | → Anvil + popsigner-lite |
### 4.3 Key Assignments
Local devnet uses Anvil's well-known deterministic keys:
| Role | Address | Private Key |
|------|---------|-------------|
| Deployer | `0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266` | `0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80` |
| Batcher | `0x70997970C51812dc3A010C7d01b50e0d17dc79C8` | `0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d` |
| Proposer | `0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC` | `0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a` |
| Admin | `0x90F79bf6EB2c4f870365E785982E1f101E93b906` | `0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6` |
**Note:** These keys are publicly known and ONLY for local development. Never use them on real networks.
---
## 5. User Journey
### Step 1: Configure Local Devnet (Dashboard)
User visits POPKins dashboard and selects:
```
┌─────────────────────────────────────────────────────────────────┐
│ CREATE NEW CHAIN │
│ │
│ Target Environment: │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
│ │ ○ Sepolia │ │ ○ Mainnet │ │ ● Local Devnet (Anvil) │ │
│ └─────────────┘ └─────────────┘ └─────────────────────────┘ │
│ │
│ Chain Name: my-local-rollup │
│ Chain ID: 42069 (auto-generated, editable) │
│ │
│ [Generate Devnet Bundle] │
│ │
│ ⚡ No funding required - runs entirely locally │
└─────────────────────────────────────────────────────────────────┘
```
### Step 2: Backend Generates Bundle
POPKins backend:
1. Spins up ephemeral Anvil instance
2. Deploys OP Stack contracts using Anvil account[0]
3. Generates L2 genesis from deployed contracts
4. Dumps Anvil state via `anvil_dumpState`
5. Packages everything into downloadable bundle
**Generation time:** ~30-60 seconds
### Step 3: Download Bundle
```
┌─────────────────────────────────────────────────────────────────┐
│ LOCAL DEVNET READY ✅ │
│ │
│ Chain: my-local-rollup │
│ Chain ID: 42069 │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 📦 DOWNLOAD DEVNET BUNDLE │ │
│ │ │ │
│ │ Includes: │ │
│ │ • Pre-deployed L1 contracts (Anvil state) │ │
│ │ • L2 genesis file │ │
│ │ • Docker Compose configuration │ │
│ │ • All config files │ │
│ │ │ │
│ │ [Download .tar.gz (15 MB)] │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ Quick Start: │
│ $ tar -xzf my-local-rollup-devnet.tar.gz │
│ $ cd my-local-rollup-devnet │
│ $ docker compose up │
│ │
│ L2 RPC will be available at http://localhost:8545 │
└─────────────────────────────────────────────────────────────────┘
```
### Step 4: Run Locally
```bash
# Extract bundle
tar -xzf my-local-rollup-devnet.tar.gz
cd my-local-rollup-devnet
# Start everything
docker compose up -d
# Check status
docker compose ps
# View logs
docker compose logs -f op-node
```
### Step 5: Develop
Developer has a fully functional OP Stack:
| Endpoint | URL |
|----------|-----|
| L2 JSON-RPC | `http://localhost:8545` |
| L2 WebSocket | `ws://localhost:8546` |
| L1 (Anvil) | `http://localhost:8547` |
| op-node RPC | `http://localhost:9545` |
---
## 6. Artifact Bundle Structure
```
my-local-rollup-devnet/
├── docker-compose.yml # Ready-to-run configuration
├── .env # Pre-filled (not a template!)
│
├── state/
│ ├── anvil-state.json # L1 state with deployed contracts
│ └── celestia-state.json # DA state (initially empty)
│
├── genesis/
│ └── genesis.json # L2 genesis (~50MB)
│
├── config/
│ ├── rollup.json # Rollup configuration
│ ├── addresses.json # All deployed contract addresses
│ └── jwt.txt # JWT secret for op-geth auth
│
├── scripts/
│ ├── start.sh # docker compose up -d
│ ├── stop.sh # docker compose down (preserves state)
│ ├── reset.sh # Reset to initial state
│ └── logs.sh # docker compose logs -f
│
└── README.md # Quick start guide
```
---
## 7. Docker Compose Configuration
```yaml
version: "3.8"
x-logging: &logging
driver: json-file
options:
max-size: "10m"
max-file: "3"
services:
# =========================================================================
# L1 - Anvil (Mock Ethereum)
# =========================================================================
anvil:
image: ghcr.io/foundry-rs/foundry:latest
restart: unless-stopped
logging: *logging
command: >
anvil
--host 0.0.0.0
--port 8545
--load-state /state/anvil-state.json
--state /state/anvil-state.json
--accounts 10
--balance 10000
volumes:
- ./state:/state
ports:
- "8547:8545" # L1 RPC (different port to avoid conflict with L2)
# =========================================================================
# DA Layer - Mock Celestia
# =========================================================================
mock-celestia:
image: ghcr.io/bidon15/mock-celestia:latest
restart: unless-stopped
logging: *logging
command: >
--load-state /state/celestia-state.json
--dump-state-path /state/celestia-state.json
--json-rpc-port 26658
--grpc-port 9090
volumes:
- ./state:/state
ports:
- "26658:26658" # JSON-RPC (bridge API)
- "9090:9090" # gRPC (core API)
# =========================================================================
# DA Server - Real op-alt-da pointing to mock Celestia
# =========================================================================
op-alt-da:
image: ghcr.io/celestiaorg/op-alt-da:latest
restart: unless-stopped
logging: *logging
environment:
- OP_ALTDA_ADDR=0.0.0.0
- OP_ALTDA_PORT=3100
- OP_ALTDA_CELESTIA_BRIDGE_ADDR=http://mock-celestia:26658
- OP_ALTDA_CELESTIA_CORE_GRPC_ADDR=mock-celestia:9090
- OP_ALTDA_CELESTIA_CORE_GRPC_TLS_ENABLED=false
- OP_ALTDA_CELESTIA_NAMESPACE=${CELESTIA_NAMESPACE}
ports:
- "3100:3100"
depends_on:
- mock-celestia
# =========================================================================
# Signing - POPSigner Lite (local, offline)
# =========================================================================
popsigner-lite:
image: ghcr.io/bidon15/popsigner-lite:latest
restart: unless-stopped
logging: *logging
environment:
# Anvil's well-known private keys (not sensitive - these are public!)
- PRIVATE_KEYS=${ANVIL_PRIVATE_KEYS}
ports:
- "8080:8080"
# =========================================================================
# L2 Execution - op-geth
# =========================================================================
op-geth:
image: us-docker.pkg.dev/oplabs-tools-artifacts/images/op-geth:v1.101408.0
restart: unless-stopped
logging: *logging
command:
- --datadir=/data
- --http
- --http.addr=0.0.0.0
- --http.port=8545
- --http.api=eth,net,web3,debug,txpool
- --ws
- --ws.addr=0.0.0.0
- --ws.port=8546
- --ws.api=eth,net,web3
- --authrpc.addr=0.0.0.0
- --authrpc.port=8551
- --authrpc.jwtsecret=/config/jwt.txt
- --gcmode=archive
- --syncmode=full
- --rollup.sequencerhttp=http://op-node:8545
volumes:
- geth-data:/data
- ./genesis/genesis.json:/genesis.json:ro
- ./config:/config:ro
ports:
- "8545:8545" # L2 JSON-RPC
- "8546:8546" # L2 WebSocket
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8545"]
interval: 10s
timeout: 5s
retries: 5
# =========================================================================
# L2 Derivation - op-node
# =========================================================================
op-node:
image: us-docker.pkg.dev/oplabs-tools-artifacts/images/op-node:v1.9.0
restart: unless-stopped
logging: *logging
command:
- op-node
- --l1=http://anvil:8545
- --l2=http://op-geth:8551
- --l2.jwt-secret=/config/jwt.txt
- --rollup.config=/config/rollup.json
- --rpc.addr=0.0.0.0
- --rpc.port=8545
- --p2p.disable
- --altda.enabled=true
- --altda.da-server=http://op-alt-da:3100
volumes:
- ./config:/config:ro
ports:
- "9545:8545"
depends_on:
op-geth:
condition: service_healthy
op-alt-da:
condition: service_started
anvil:
condition: service_started
# =========================================================================
# Batcher - op-batcher (signs via popsigner-lite)
# =========================================================================
op-batcher:
image: us-docker.pkg.dev/oplabs-tools-artifacts/images/op-batcher:v1.9.0
restart: unless-stopped
logging: *logging
command:
- op-batcher
- --l1-eth-rpc=http://anvil:8545
- --l2-eth-rpc=http://op-geth:8545
- --rollup-rpc=http://op-node:8545
- --signer.endpoint=http://popsigner-lite:8080
- --signer.address=${BATCHER_ADDRESS}
- --altda.enabled=true
- --altda.da-server=http://op-alt-da:3100
- --max-channel-duration=1
- --sub-safety-margin=4
depends_on:
- op-node
- op-geth
- op-alt-da
- popsigner-lite
# =========================================================================
# Proposer - op-proposer (signs via popsigner-lite)
# =========================================================================
op-proposer:
image: us-docker.pkg.dev/oplabs-tools-artifacts/images/op-proposer:v1.9.0
restart: unless-stopped
logging: *logging
command:
- op-proposer
- --l1-eth-rpc=http://anvil:8545
- --rollup-rpc=http://op-node:8545
- --l2oo-address=${L2_OUTPUT_ORACLE_ADDRESS}
- --signer.endpoint=http://popsigner-lite:8080
- --signer.address=${PROPOSER_ADDRESS}
depends_on:
- op-node
- popsigner-lite
volumes:
geth-data:
networks:
default:
name: ${CHAIN_NAME}-devnet
```
---
## 8. Environment File (.env)
```bash
# =============================================================================
# LOCAL DEVNET CONFIGURATION
# Chain: my-local-rollup
# Chain ID: 42069
# =============================================================================
# Chain identity
CHAIN_NAME=my-local-rollup
CHAIN_ID=42069
# Role addresses (Anvil's deterministic accounts)
DEPLOYER_ADDRESS=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
BATCHER_ADDRESS=0x70997970C51812dc3A010C7d01b50e0d17dc79C8
PROPOSER_ADDRESS=0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC
ADMIN_ADDRESS=0x90F79bf6EB2c4f870365E785982E1f101E93b906
# Contract addresses (from deployment)
L2_OUTPUT_ORACLE_ADDRESS=0x...
# Anvil private keys (comma-separated, loaded by popsigner-lite)
ANVIL_PRIVATE_KEYS=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80,0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d,0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a,0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6
# Celestia namespace (generated for this devnet)
CELESTIA_NAMESPACE=00000000000000000000000000000000000000000000000000000000abcd
```
---
## 9. New Components to Build
### 9.1 mock-celestia
**Purpose:** Mock Celestia node that implements the APIs op-alt-da expects.
**Interfaces:**
| API | Port | Purpose |
|-----|------|---------|
| gRPC | 9090 | `BlobService.Submit` - accept blob submissions |
| JSON-RPC | 26658 | `blob.Get` - retrieve blobs |
| HTTP | 8080 | `/dump`, `/health` - state management |
**Features:**
- In-memory blob storage
- Instant "finality" (no consensus delay)
- State dump/load (like Anvil)
- No keyring/signature verification needed
**Size:** ~500 lines Go
### 9.2 popsigner-lite
**Purpose:** Minimal local signing service with Anvil's known keys.
**Interface:**
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/` | POST | JSON-RPC: `eth_signTransaction` |
| `/health` | GET | Health check |
**Features:**
- Pre-loaded with Anvil's 10 deterministic private keys
- Same JSON-RPC interface as POPSigner Cloud
- No authentication required
- No database, no OpenBao
**Size:** ~200 lines Go
### 9.3 Anvil State Generator (Backend)
**Purpose:** Deploy OP Stack contracts to Anvil and dump state.
**Location:** `control-plane/internal/bootstrap/opstack/anvil_deployer.go`
**Flow:**
```go
func (o *Orchestrator) GenerateLocalDevnet(ctx context.Context, cfg *LocalDevnetConfig) (*DevnetBundle, error) {
// 1. Start ephemeral Anvil container
anvil, err := startAnvil(ctx)
if err != nil {
return nil, err
}
defer anvil.Stop()
// 2. Create signer using Anvil's known key
deployerKey := anvil.Account(0)
signerAdapter := NewAnvilSignerAdapter(deployerKey)
// 3. Deploy OP Stack contracts (reuse existing deployer)
deployConfig := &DeploymentConfig{
L1RPC: anvil.RPCURL(),
L1ChainID: 31337, // Anvil's chain ID
ChainName: cfg.ChainName,
ChainID: cfg.ChainID,
BatcherAddr: anvil.Account(1).Address,
ProposerAddr: anvil.Account(2).Address,
AdminAddr: anvil.Account(3).Address,
}
result, err := o.deployer.Deploy(ctx, deployConfig, signerAdapter, nil)
if err != nil {
return nil, err
}
// 4. Dump Anvil state
stateJSON, err := anvil.DumpState()
if err != nil {
return nil, err
}
// 5. Generate docker-compose and bundle
return &DevnetBundle{
AnvilState: stateJSON,
Genesis: result.Genesis,
RollupConfig: result.RollupConfig,
Addresses: result.Addresses,
DockerCompose: generateLocalCompose(cfg, result),
EnvFile: generateEnvFile(cfg, result),
}, nil
}
```
---
## 10. API Endpoints
### Create Local Devnet
```http
POST /api/v1/deployments
Content-Type: application/json
{
"chainType": "opstack",
"targetEnvironment": "local", // NEW: indicates local devnet
"name": "my-local-rollup",
"l2ChainId": 42069,
"config": {
// Optional overrides
}
}
```
### Response
```json
{
"id": "01HXYZ...",
"status": "generating",
"targetEnvironment": "local",
"estimatedTime": "45s"
}
```
### Download Bundle
```http
GET /api/v1/deployments/{id}/bundle
Authorization: Bearer <token>
Response: application/gzip
Content-Disposition: attachment; filename="my-local-rollup-devnet.tar.gz"
```
---
## 11. CLI Support
```bash
# Create local devnet
popctl bootstrap create \
--name my-rollup \
--chain-id 42069 \
--target local
# Download bundle
popctl bootstrap download <deployment-id> --output ./my-rollup/
# Or one-liner
popctl bootstrap create --name my-rollup --target local --download ./my-rollup/
```
---
## 12. State Management
### Persistence
All state is persisted in the `./state/` directory:
| File | Contents | Persistence |
|------|----------|-------------|
| `anvil-state.json` | L1 accounts, contracts, storage | Auto-saved on shutdown |
| `celestia-state.json` | DA blobs | Auto-saved on shutdown |
| `geth-data/` | L2 chain data | Docker volume |
### Stop/Resume
```bash
# Stop (preserves state)
docker compose down
# Resume (loads state)
docker compose up -d
```
### Reset
```bash
# Reset to initial state
./scripts/reset.sh
# (Copies initial state files back, removes geth-data volume)
docker compose up -d
```
---
## 13. Upgrade Path
When developers are ready to move from local to testnet:
| Local Devnet | → | Sepolia (Real) |
|--------------|---|----------------|
| `popsigner-lite:8080` | → | `rpc.popsigner.com` |
| `anvil:8545` | → | Sepolia RPC |
| `mock-celestia` | → | Celestia Mocha |
| No auth | → | API key auth |
| Instant finality | → | Real block times |
**Changes required:**
1. Update `.env` with real endpoints and credentials
2. Re-deploy contracts to Sepolia (via POPKins)
3. Fund deployer address with testnet ETH
The docker-compose structure and `--signer.*` flags remain identical.
---
## 14. Success Metrics
| Metric | Target |
|--------|--------|
| Bundle generation time | < 60 seconds |
| Bundle download size | < 20 MB (compressed) |
| Time to first L2 block | < 30 seconds after `docker compose up` |
| Works offline | 100% |
| Zero config required | `docker compose up` just works |
---
## 15. Implementation Checklist
### New Components
| Task | Priority | Estimated Effort |
|------|----------|------------------|
| `mock-celestia` service | P0 | 2-3 days |
| `popsigner-lite` service | P0 | 1 day |
| Anvil state generator | P0 | 2 days |
| Docker Compose template (local) | P0 | 1 day |
| Bundle generator | P0 | 1 day |
### Dashboard Updates
| Task | Priority | Estimated Effort |
|------|----------|------------------|
| "Local Devnet" option in chain creation | P1 | 0.5 day |
| Bundle download UI | P1 | 0.5 day |
| Quick start instructions | P1 | 0.5 day |
### CLI Updates
| Task | Priority | Estimated Effort |
|------|----------|------------------|
| `--target local` flag | P2 | 0.5 day |
| `--download` option | P2 | 0.5 day |
---
## 16. Out of Scope (v1)
| Feature | Reason |
|---------|--------|
| Block explorer | Can add later, not critical for MVP |
| L2 faucet | Anvil accounts have unlimited ETH |
| Multiple L2 chains | Start with single chain |
| Custom key configuration | Anvil keys are sufficient |
| Windows native (non-Docker) | Docker is the supported path |
---
## 17. Security Considerations
### Local Development Only
- Anvil's private keys are **publicly known** and hardcoded
- Never use these keys on real networks
- Bundle includes clear warnings in README
### No Sensitive Data
- No real API keys in bundle
- No real private keys (only Anvil's known keys)
- No network access required
---
## 18. Appendix: Quick Reference
### Endpoints (After `docker compose up`)
| Service | URL |
|---------|-----|
| L2 JSON-RPC | http://localhost:8545 |
| L2 WebSocket | ws://localhost:8546 |
| L1 (Anvil) | http://localhost:8547 |
| op-node RPC | http://localhost:9545 |
| op-alt-da | http://localhost:3100 |
| popsigner-lite | http://localhost:8080 |
### Accounts (Pre-funded)
| # | Address | ETH Balance |
|---|---------|-------------|
| 0 | 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 | 10000 |
| 1 | 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 | 10000 |
| 2 | 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC | 10000 |
| ... | ... | ... |
### Commands
```bash
docker compose up -d # Start
docker compose down # Stop (preserves state)
docker compose logs -f # View logs
./scripts/reset.sh # Reset to initial state
```