mirror of
https://github.com/hanzoai/bootnode.git
synced 2026-09-30 20:46:32 +00:00
feat: Go rewrite scaffold — ZAP-first, Mount() per HIP-0106 (#1)
Adds a Go subsystem alongside the existing Python FastAPI service so
bootnode can fold into `superbase` per HIP-0106. The Python service in
`api/bootnode/` stays untouched.
What landed:
- `schema/bootnode.zap` — public typed surface (chains, RPC, tokens,
NFTs, transfers, smart wallets, ERC-4337, gas, webhooks, fleets,
nodes, usage). No `.capnp` anywhere — ZAP only, per brand policy.
- `zap/gen/` — hand-rolled Go bindings that match what `zapc generate`
emits, so `go build` works without zapc installed locally.
- `api/mount.go` — the HIP-0106 contract:
func Mount(r chi.Router, deps Deps) error
mounts every FastAPI-parity route group through `api/handlers/`.
- `api/deps.go` — cross-subsystem ZAP client interfaces (IAM, KMS,
Base, O11y, Commerce). No business logic, no hard imports — keeps
the Bootnode module independent of sibling subsystems until they
ship their typed clients.
- `api/handlers/` — 149 chi routes mounted (Python has 150). Each
handler body is a 501 stub with a `TODO(bootnode-port)` pointing at
the Python file + symbol to port.
- `api/middleware/` — IAM-backed auth, OTEL-friendly telemetry,
per-org rate limit (stub).
- `api/store/` — per-tenant SQLite access via `deps.Base.Open(orgID,
"bootnode")` per HIP-0302.
- `api/web3/` — chain registry + RPC pool surface (production version
prefers luxfi/* over upstream go-ethereum).
- `cmd/bootnode/main.go` — standalone shim; same `api.Mount` superbase
calls in production.
- `docs/PYTHON_TO_GO_PORT.md` — endpoint-by-endpoint port map.
Build: `go build ./...` and `go vet ./...` clean. Standalone shim
smokes 200 on /health, /ready, /v1/chains; 401 on auth-required
endpoints with no JWT; 501 on every TODO route.
Out of scope (not in this commit):
- Handler bodies (Phase 2 — porting in RPC → tokens → NFTs → ...
priority order).
- superbase wiring (lands when the superbase repo opens its main).
- Python service removal (stays until handlers reach prod parity).
This commit is contained in:
@@ -2,6 +2,14 @@
|
||||
|
||||
**Web3 Backend in a Box** - Run locally or deploy to cloud.
|
||||
|
||||
> **Go rewrite (HIP-0106) in progress.** The current production
|
||||
> service is the Python FastAPI app under [`api/`](api/). A Go
|
||||
> scaffold that mounts into [`superbase`](../superbase) per HIP-0106
|
||||
> lives alongside it — `api/`, `cmd/bootnode/`, `schema/`,
|
||||
> `zap/`. See [`docs/PYTHON_TO_GO_PORT.md`](docs/PYTHON_TO_GO_PORT.md)
|
||||
> for the endpoint-by-endpoint port map and the new
|
||||
> [Go README section](#go-rewrite-hip-0106) below.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
@@ -61,3 +69,43 @@ Visit [web3.hanzo.ai](https://web3.hanzo.ai) for managed hosting.
|
||||
## License
|
||||
|
||||
Apache-2.0
|
||||
|
||||
## Go rewrite (HIP-0106)
|
||||
|
||||
The Go subsystem mounts into `superbase` (the unified Hanzo Cloud
|
||||
binary). It exposes a single `Mount(r chi.Router, deps api.Deps) error`
|
||||
entry point and a public ZAP schema (`schema/bootnode.zap`) covering
|
||||
every cross-subsystem type.
|
||||
|
||||
```
|
||||
bootnode/
|
||||
schema/bootnode.zap # public typed surface (ZAP)
|
||||
zap/gen/ # zapc-generated Go (hand-stubs today)
|
||||
api/ # subsystem code (chi handlers + store)
|
||||
mount.go # HIP-0106 contract
|
||||
deps.go # IAM/KMS/Base/O11y/Commerce interfaces
|
||||
handlers/ # one MountXxx per FastAPI route group
|
||||
middleware/ # auth, telemetry, rate-limit
|
||||
store/ # per-tenant SQLite via deps.Base
|
||||
web3/ # chain registry + RPC pool
|
||||
cmd/bootnode/main.go # standalone dev shim
|
||||
docs/PYTHON_TO_GO_PORT.md # endpoint-by-endpoint mapping
|
||||
```
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
go build ./...
|
||||
go vet ./...
|
||||
go run ./cmd/bootnode # listens on :8100 (BOOTNODE_ADDR to override)
|
||||
```
|
||||
|
||||
### Generate ZAP bindings
|
||||
|
||||
```bash
|
||||
cd zap && make gen # no-op if zapc isn't installed
|
||||
```
|
||||
|
||||
`zapc` lives at `~/work/hanzo/zap/zapc-rs/`. Brand policy: no
|
||||
`.capnp` files, no "Cap'n Proto" references on the public surface
|
||||
(HIP-0106).
|
||||
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
// Package api is the Bootnode subsystem entry point. It exposes
|
||||
// Mount(r, deps) per HIP-0106 so superbase can fold Bootnode into the
|
||||
// unified Hanzo Cloud binary, and a standalone main shim
|
||||
// (cmd/bootnode) wires the same Mount onto a fresh chi.Mux.
|
||||
//
|
||||
// No business logic lives in main — everything routes through Mount,
|
||||
// every cross-subsystem call goes through deps, and every typed
|
||||
// surface is defined in ../schema/bootnode.zap.
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
// Deps is the cross-subsystem contract Bootnode consumes. Every
|
||||
// non-trivial dependency is a typed ZAP client (in-process or RPC)
|
||||
// resolved by superbase at startup. Subsystems that aren't enabled
|
||||
// pass nil — handlers fail closed.
|
||||
//
|
||||
// The struct deliberately uses interface fields so superbase can hand
|
||||
// us in-process implementations (direct Go calls through the local
|
||||
// ZAP dispatcher) and a future split deployment can hand us an RPC
|
||||
// client without touching this package.
|
||||
type Deps struct {
|
||||
// Brand identifies the deployment (hanzo / lux / zoo / bootnode).
|
||||
// Drives logo, CORS allow-list, KMS prefix, IAM org default.
|
||||
Brand string
|
||||
|
||||
// Logger is the structured logger plumbed in by superbase. Required.
|
||||
Logger *slog.Logger
|
||||
|
||||
// IAM authenticates incoming JWTs. nil → fail-closed at the
|
||||
// auth middleware. The interface mirrors hanzo/iam/zap.Client; we
|
||||
// keep a local interface so the Bootnode module doesn't take a
|
||||
// hard dependency on the IAM module until the import graph is wired.
|
||||
IAM IAMClient
|
||||
|
||||
// KMS resolves secrets. nil → secrets cannot be loaded; the
|
||||
// affected handlers return 503.
|
||||
KMS KMSClient
|
||||
|
||||
// Base is the per-tenant SQLite gateway (HIP-0302). nil → store
|
||||
// layer falls back to an in-memory map (dev only).
|
||||
Base BaseClient
|
||||
|
||||
// O11y emits traces/metrics/logs through the shared OTEL pipeline.
|
||||
// nil → telemetry middleware no-ops.
|
||||
O11y O11yClient
|
||||
|
||||
// Commerce is the upstream billing service Bootnode reports usage
|
||||
// to. nil → usage records accumulate locally and never sync.
|
||||
Commerce CommerceClient
|
||||
}
|
||||
|
||||
// IAMClient is the minimal interface Bootnode needs from hanzo/iam.
|
||||
// Once the iam Go module exposes its zap client we replace this with
|
||||
// a type alias to `iamzap.Client`.
|
||||
type IAMClient interface {
|
||||
// VerifyJWT validates a bearer token and returns the canonical
|
||||
// (orgID, userID, email) tuple. Implementations MUST fail closed
|
||||
// on signature mismatch / expiry / unknown issuer.
|
||||
VerifyJWT(ctx context.Context, bearer string) (orgID, userID, email string, err error)
|
||||
}
|
||||
|
||||
// KMSClient is the minimal interface Bootnode needs from hanzo/kms.
|
||||
type KMSClient interface {
|
||||
GetSecret(ctx context.Context, ref string) (string, error)
|
||||
}
|
||||
|
||||
// BaseClient is the per-tenant SQLite gateway (HIP-0302). Bootnode
|
||||
// asks for a connection scoped to (orgID, "bootnode") and stores its
|
||||
// projects / api_keys / webhooks / usage there.
|
||||
type BaseClient interface {
|
||||
// Open returns a per-tenant database handle. The opaque type is a
|
||||
// chosen-by-Base value (e.g. *sql.DB or a typed wrapper); we keep
|
||||
// it `any` here to avoid pulling in database/sql before the Base
|
||||
// module's public ZAP client lands.
|
||||
Open(ctx context.Context, orgID, namespace string) (any, error)
|
||||
}
|
||||
|
||||
// O11yClient is the minimal observability surface. Implementations
|
||||
// should be safe to call with a nil receiver — handlers should never
|
||||
// have to nil-check.
|
||||
type O11yClient interface {
|
||||
RecordRequest(ctx context.Context, route string, status int, durationMs int64)
|
||||
}
|
||||
|
||||
// CommerceClient mirrors the commerce ZAP RecordUsage shape so we can
|
||||
// report compute-unit accounting upstream.
|
||||
type CommerceClient interface {
|
||||
RecordUsage(ctx context.Context, orgID, projectID, chain, method string, computeUnits int32) error
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// MountAuth registers /v1/auth/* (local-dev auth + IAM OAuth bridge).
|
||||
// In production every authenticated request flows through deps.IAM —
|
||||
// the local register/login paths exist for dev parity only.
|
||||
func MountAuth(r chi.Router, _ Deps) {
|
||||
r.Route("/auth", func(r chi.Router) {
|
||||
r.Post("/register", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/auth/__init__.py:register —
|
||||
// MUST bcrypt the password (golang.org/x/crypto/bcrypt). Never plaintext.
|
||||
stub(w, "auth/register")
|
||||
})
|
||||
r.Post("/login", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/auth/__init__.py:login
|
||||
stub(w, "auth/login")
|
||||
})
|
||||
r.Get("/me", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/auth/__init__.py:me
|
||||
stub(w, "auth/me")
|
||||
})
|
||||
r.Post("/oauth/callback", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/auth/__init__.py:oauth_callback —
|
||||
// exchange code at hanzo.id, return bearer.
|
||||
stub(w, "auth/oauth/callback")
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
bootnodezap "github.com/hanzoai/bootnode/zap/gen"
|
||||
"github.com/hanzoai/bootnode/api/middleware"
|
||||
)
|
||||
|
||||
// MountAuthKeys registers /v1/auth-keys/* — project + API key admin.
|
||||
// The plaintext API key is returned ONCE on creation and never
|
||||
// stored: deps.Service.CreateAPIKey is responsible for bcrypt'ing the
|
||||
// secret before writing to deps.Base.
|
||||
func MountAuthKeys(r chi.Router, d Deps) {
|
||||
r.Route("/auth-keys", func(r chi.Router) {
|
||||
r.Post("/projects", middleware.RequireAuth(func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/auth_keys.py:create_project
|
||||
stub(w, "auth-keys/projects")
|
||||
}))
|
||||
r.Get("/projects/{project_id}", middleware.RequireAuth(func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/auth_keys.py:get_project
|
||||
stub(w, "auth-keys/projects/{id}")
|
||||
}))
|
||||
r.Post("/keys", middleware.RequireAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
ProjectID string `json:"projectId"`
|
||||
Name string `json:"name"`
|
||||
Scopes []string `json:"scopes"`
|
||||
}
|
||||
if !readJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
res, err := d.Service.CreateAPIKey(r.Context(), middleware.OrgID(r.Context()), req.ProjectID, req.Name, req.Scopes)
|
||||
if err != nil {
|
||||
serviceErr(w, err, "auth-keys/keys POST")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, res)
|
||||
}))
|
||||
r.Get("/keys", middleware.RequireAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
keys, err := d.Service.ListAPIKeys(r.Context(), middleware.OrgID(r.Context()))
|
||||
if err != nil {
|
||||
serviceErr(w, err, "auth-keys/keys GET")
|
||||
return
|
||||
}
|
||||
if keys == nil {
|
||||
keys = []bootnodezap.APIKey{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, keys)
|
||||
}))
|
||||
r.Delete("/keys/{key_id}", middleware.RequireAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "key_id")
|
||||
ok, err := d.Service.RevokeAPIKey(r.Context(), middleware.OrgID(r.Context()), id)
|
||||
if err != nil {
|
||||
serviceErr(w, err, "auth-keys/keys/{id} DELETE")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": ok})
|
||||
}))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/hanzoai/bootnode/api/web3"
|
||||
)
|
||||
|
||||
// MountChains registers /v1/chains/* — chain discovery.
|
||||
func MountChains(r chi.Router, _ Deps) {
|
||||
r.Route("/chains", func(r chi.Router) {
|
||||
r.Get("/", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"chains": web3.AllChains(),
|
||||
"count": len(web3.AllChains()),
|
||||
})
|
||||
})
|
||||
r.Get("/{chain}", func(w http.ResponseWriter, r *http.Request) {
|
||||
name := chi.URLParam(r, "chain")
|
||||
info, ok := web3.ChainByName(name)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusNotFound, errBody{"unknown chain: " + name})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, info)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Package handlers holds the chi route mounters and HTTP handlers
|
||||
// for the Bootnode subsystem. Each MountXxx function wires one
|
||||
// FastAPI-parity route group; handler bodies are scaffolded as 501
|
||||
// stubs with TODO(bootnode-port) markers pointing at the Python file
|
||||
// + symbol they need to replace.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
bootnodezap "github.com/hanzoai/bootnode/zap/gen"
|
||||
)
|
||||
|
||||
// Deps is the per-handler dependency bundle. It mirrors api.Deps but
|
||||
// without the import cycle: api/mount.go owns the canonical Deps
|
||||
// struct and forwards a value-copied view here.
|
||||
type Deps struct {
|
||||
Logger *slog.Logger
|
||||
Service bootnodezap.Service
|
||||
IAM IAMClient
|
||||
KMS KMSClient
|
||||
Base BaseClient
|
||||
O11y O11yClient
|
||||
Commerce CommerceClient
|
||||
}
|
||||
|
||||
// IAMClient mirrors api.IAMClient (interfaces are nominal in Go but
|
||||
// we keep the type local to avoid the cycle).
|
||||
type IAMClient interface {
|
||||
VerifyJWT(ctx context.Context, bearer string) (orgID, userID, email string, err error)
|
||||
}
|
||||
|
||||
// KMSClient mirrors api.KMSClient.
|
||||
type KMSClient interface {
|
||||
GetSecret(ctx context.Context, ref string) (string, error)
|
||||
}
|
||||
|
||||
// BaseClient mirrors api.BaseClient.
|
||||
type BaseClient interface {
|
||||
Open(ctx context.Context, orgID, namespace string) (any, error)
|
||||
}
|
||||
|
||||
// O11yClient mirrors api.O11yClient.
|
||||
type O11yClient interface {
|
||||
RecordRequest(ctx context.Context, route string, status int, durationMs int64)
|
||||
}
|
||||
|
||||
// CommerceClient mirrors api.CommerceClient.
|
||||
type CommerceClient interface {
|
||||
RecordUsage(ctx context.Context, orgID, projectID, chain, method string, computeUnits int32) error
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Health is the liveness endpoint mounted at /health.
|
||||
func Health(_ Deps) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"status": "healthy",
|
||||
"version": "2.0.0",
|
||||
"impl": "go",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Ready is the readiness endpoint mounted at /ready. It probes every
|
||||
// non-nil dep and returns 503 when one is unreachable.
|
||||
func Ready(d Deps) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/main.py readiness checks —
|
||||
// dep probes (KMS, Base, Commerce). For now we just report wiring.
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"iam": d.IAM != nil,
|
||||
"kms": d.KMS != nil,
|
||||
"base": d.Base != nil,
|
||||
"o11y": d.O11y != nil,
|
||||
"commerce": d.Commerce != nil,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// MountFleets registers /v1/fleets/* — multi-replica node deployments.
|
||||
func MountFleets(r chi.Router, _ Deps) {
|
||||
r.Route("/fleets", func(r chi.Router) {
|
||||
r.Post("/", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/fleets/__init__.py:create
|
||||
stub(w, "fleets POST")
|
||||
})
|
||||
r.Get("/", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/fleets/__init__.py:list
|
||||
stub(w, "fleets GET")
|
||||
})
|
||||
r.Get("/stats", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/fleets/__init__.py:stats
|
||||
stub(w, "fleets/stats")
|
||||
})
|
||||
r.Get("/chains", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/fleets/__init__.py:chains
|
||||
stub(w, "fleets/chains")
|
||||
})
|
||||
r.Get("/networks", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/fleets/__init__.py:networks
|
||||
stub(w, "fleets/networks")
|
||||
})
|
||||
r.Get("/clusters", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/fleets/__init__.py:list_clusters
|
||||
stub(w, "fleets/clusters GET")
|
||||
})
|
||||
r.Post("/clusters", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/fleets/__init__.py:create_cluster
|
||||
stub(w, "fleets/clusters POST")
|
||||
})
|
||||
r.Delete("/clusters/{cluster_id}", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/fleets/__init__.py:delete_cluster
|
||||
stub(w, "fleets/clusters/{id} DELETE")
|
||||
})
|
||||
r.Get("/{chain}/{cluster_id}/{network}", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/fleets/__init__.py:get_fleet
|
||||
stub(w, "fleets/{chain}/{cluster}/{network}")
|
||||
})
|
||||
r.Patch("/{chain}/{cluster_id}/{network}", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/fleets/__init__.py:update_fleet
|
||||
stub(w, "fleets/{chain}/{cluster}/{network} PATCH")
|
||||
})
|
||||
r.Delete("/{chain}/{cluster_id}/{network}", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/fleets/__init__.py:delete_fleet
|
||||
stub(w, "fleets/{chain}/{cluster}/{network} DELETE")
|
||||
})
|
||||
r.Post("/{chain}/{cluster_id}/{network}/scale", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/fleets/__init__.py:scale_fleet
|
||||
stub(w, "fleets scale")
|
||||
})
|
||||
r.Post("/{chain}/{cluster_id}/{network}/restart", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/fleets/__init__.py:restart_fleet
|
||||
stub(w, "fleets restart")
|
||||
})
|
||||
r.Get("/{chain}/{cluster_id}/{network}/pods/{pod_name}/logs", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/fleets/__init__.py:pod_logs
|
||||
stub(w, "fleets pod logs")
|
||||
})
|
||||
r.Get("/{chain}/{cluster_id}/{network}/pods/{pod_name}/health", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/fleets/__init__.py:pod_health
|
||||
stub(w, "fleets pod health")
|
||||
})
|
||||
_ = chi.URLParam
|
||||
})
|
||||
}
|
||||
|
||||
// MountNodes registers /v1/nodes/* — single-instance node lifecycle.
|
||||
func MountNodes(r chi.Router, _ Deps) {
|
||||
r.Route("/nodes", func(r chi.Router) {
|
||||
r.Get("/", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/nodes/__init__.py:list
|
||||
stub(w, "nodes GET")
|
||||
})
|
||||
r.Get("/presets", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/nodes/__init__.py:presets
|
||||
stub(w, "nodes/presets")
|
||||
})
|
||||
r.Get("/supported-chains", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/nodes/__init__.py:supported_chains
|
||||
stub(w, "nodes/supported-chains")
|
||||
})
|
||||
r.Post("/", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/nodes/__init__.py:create —
|
||||
// docker-managed nodes via deps.Compute (was direct docker SDK).
|
||||
stub(w, "nodes POST")
|
||||
})
|
||||
r.Get("/stats", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/nodes/__init__.py:stats
|
||||
stub(w, "nodes/stats")
|
||||
})
|
||||
r.Get("/metrics/all", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/nodes/__init__.py:metrics_all
|
||||
stub(w, "nodes/metrics/all")
|
||||
})
|
||||
r.Get("/docker/status", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/nodes/__init__.py:docker_status
|
||||
stub(w, "nodes/docker/status")
|
||||
})
|
||||
r.Get("/{node_id}", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/nodes/__init__.py:get
|
||||
stub(w, "nodes/{id}")
|
||||
})
|
||||
r.Delete("/{node_id}", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/nodes/__init__.py:delete
|
||||
stub(w, "nodes/{id} DELETE")
|
||||
})
|
||||
r.Post("/{node_id}/start", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/nodes/__init__.py:start
|
||||
stub(w, "nodes/{id}/start")
|
||||
})
|
||||
r.Post("/{node_id}/stop", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/nodes/__init__.py:stop
|
||||
stub(w, "nodes/{id}/stop")
|
||||
})
|
||||
r.Post("/{node_id}/rpc", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/nodes/__init__.py:rpc_proxy
|
||||
stub(w, "nodes/{id}/rpc")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// MountInfra registers /v1/infra/* — K8s clusters / volumes / snapshots / clones.
|
||||
func MountInfra(r chi.Router, _ Deps) {
|
||||
r.Route("/infra", func(r chi.Router) {
|
||||
// Clusters
|
||||
r.Get("/clusters", stubHandler("infra/clusters GET", "list_clusters"))
|
||||
r.Get("/clusters/{cluster_id}", stubHandler("infra/clusters/{id}", "get_cluster"))
|
||||
r.Post("/clusters", stubHandler("infra/clusters POST", "create_cluster"))
|
||||
r.Delete("/clusters/{cluster_id}", stubHandler("infra/clusters/{id} DELETE", "delete_cluster"))
|
||||
r.Post("/clusters/{cluster_id}/scale", stubHandler("infra/clusters/{id}/scale", "scale_cluster"))
|
||||
r.Get("/clusters/{cluster_id}/kubeconfig", stubHandler("infra/clusters/{id}/kubeconfig", "kubeconfig"))
|
||||
// Volumes
|
||||
r.Get("/volumes", stubHandler("infra/volumes GET", "list_volumes"))
|
||||
r.Get("/volumes/{volume_id}", stubHandler("infra/volumes/{id}", "get_volume"))
|
||||
r.Post("/volumes", stubHandler("infra/volumes POST", "create_volume"))
|
||||
r.Delete("/volumes/{volume_id}", stubHandler("infra/volumes/{id} DELETE", "delete_volume"))
|
||||
r.Post("/volumes/{volume_id}/attach", stubHandler("infra/volumes/{id}/attach", "attach_volume"))
|
||||
r.Post("/volumes/{volume_id}/detach", stubHandler("infra/volumes/{id}/detach", "detach_volume"))
|
||||
r.Post("/volumes/{volume_id}/resize", stubHandler("infra/volumes/{id}/resize", "resize_volume"))
|
||||
// Snapshots
|
||||
r.Get("/snapshots", stubHandler("infra/snapshots GET", "list_snapshots"))
|
||||
r.Post("/snapshots", stubHandler("infra/snapshots POST", "create_snapshot"))
|
||||
r.Delete("/snapshots/{snapshot_id}", stubHandler("infra/snapshots/{id} DELETE", "delete_snapshot"))
|
||||
// Clone
|
||||
r.Post("/clone", stubHandler("infra/clone POST", "create_clone"))
|
||||
r.Get("/clone", stubHandler("infra/clone GET", "list_clones"))
|
||||
r.Get("/clone/{clone_id}", stubHandler("infra/clone/{id}", "get_clone"))
|
||||
r.Delete("/clone/{clone_id}", stubHandler("infra/clone/{id} DELETE", "cancel_clone"))
|
||||
// Services
|
||||
r.Get("/services", stubHandler("infra/services GET", "list_services"))
|
||||
r.Get("/services/{service}/status", stubHandler("infra/services/{s}/status", "service_status"))
|
||||
r.Post("/services/{service}/deploy", stubHandler("infra/services/{s}/deploy", "service_deploy"))
|
||||
r.Post("/services/{service}/scale", stubHandler("infra/services/{s}/scale", "service_scale"))
|
||||
r.Post("/services/{service}/restart", stubHandler("infra/services/{s}/restart", "service_restart"))
|
||||
r.Delete("/services/{service}", stubHandler("infra/services/{s} DELETE", "service_delete"))
|
||||
r.Get("/services/{service}/logs", stubHandler("infra/services/{s}/logs", "service_logs"))
|
||||
r.Get("/services/{service}/health", stubHandler("infra/services/{s}/health", "service_health"))
|
||||
// Stats
|
||||
r.Get("/stats", stubHandler("infra/stats", "infra_stats"))
|
||||
})
|
||||
}
|
||||
|
||||
// stubHandler is shorthand for the giant infra routing table.
|
||||
func stubHandler(route, pyFunc string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/infra/__init__.py:<pyFunc>
|
||||
stub(w, route+" — port: bootnode/api/infra/__init__.py:"+pyFunc)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// MountTeam registers /v1/team/* — org-scoped membership admin.
|
||||
func MountTeam(r chi.Router, _ Deps) {
|
||||
r.Route("/team", func(r chi.Router) {
|
||||
r.Get("/", stubHandler("team GET", "list_members"))
|
||||
r.Post("/", stubHandler("team POST", "invite_member"))
|
||||
r.Patch("/{member_id}", stubHandler("team/{id} PATCH", "update_member"))
|
||||
r.Delete("/{member_id}", stubHandler("team/{id} DELETE", "remove_member"))
|
||||
})
|
||||
}
|
||||
|
||||
// MountNetworks registers /v1/networks/* — managed network lifecycle.
|
||||
func MountNetworks(r chi.Router, _ Deps) {
|
||||
r.Route("/networks", func(r chi.Router) {
|
||||
r.Post("/", stubHandler("networks POST", "create_network"))
|
||||
r.Get("/", stubHandler("networks GET", "list_networks"))
|
||||
r.Get("/{network_id}", stubHandler("networks/{id}", "get_network"))
|
||||
r.Post("/{network_id}/scale", stubHandler("networks/{id}/scale", "scale_network"))
|
||||
r.Delete("/{network_id}", stubHandler("networks/{id} DELETE", "delete_network"))
|
||||
})
|
||||
}
|
||||
|
||||
// MountObservability registers /v1/o11y/*. The actual telemetry pipe
|
||||
// belongs to the O11y subsystem — these endpoints are the
|
||||
// query/control plane Bootnode exposes for its own dashboards.
|
||||
func MountObservability(r chi.Router, _ Deps) {
|
||||
r.Route("/o11y", func(r chi.Router) {
|
||||
r.Get("/health", stubHandler("o11y/health", "platform_health"))
|
||||
r.Get("/metrics", stubHandler("o11y/metrics", "list_metrics"))
|
||||
r.Get("/metrics/{service}", stubHandler("o11y/metrics/{service}", "service_metrics"))
|
||||
r.Get("/alerts", stubHandler("o11y/alerts GET", "list_alerts"))
|
||||
r.Post("/alerts", stubHandler("o11y/alerts POST", "create_alert"))
|
||||
r.Get("/service-map", stubHandler("o11y/service-map", "service_map"))
|
||||
r.Get("/logs", stubHandler("o11y/logs", "query_logs"))
|
||||
r.Get("/events", stubHandler("o11y/events", "query_events"))
|
||||
})
|
||||
}
|
||||
|
||||
// MountChat registers /v1/chat/* — AI inference passthrough. Routes
|
||||
// forward to deps.LLMGateway in production; stubbed in skeleton.
|
||||
func MountChat(r chi.Router, _ Deps) {
|
||||
r.Route("/chat", func(r chi.Router) {
|
||||
r.Post("/completions", stubHandler("chat/completions", "completions"))
|
||||
r.Get("/models", stubHandler("chat/models", "list_models"))
|
||||
})
|
||||
}
|
||||
|
||||
// MountBilling registers /v1/billing/* — usage, quota, invoices,
|
||||
// checkout. Payment processing belongs to Commerce; Bootnode just
|
||||
// reports usage and reads back per-org tiers.
|
||||
func MountBilling(r chi.Router, _ Deps) {
|
||||
r.Route("/billing", func(r chi.Router) {
|
||||
r.Get("/usage", stubHandler("billing/usage", "get_usage"))
|
||||
r.Get("/usage/summary", stubHandler("billing/usage/summary", "usage_summary"))
|
||||
r.Get("/subscription", stubHandler("billing/subscription", "get_subscription"))
|
||||
r.Post("/subscription/upgrade", stubHandler("billing/subscription/upgrade", "upgrade"))
|
||||
r.Post("/subscription/downgrade", stubHandler("billing/subscription/downgrade", "downgrade"))
|
||||
r.Get("/invoices", stubHandler("billing/invoices", "list_invoices"))
|
||||
r.Get("/tiers", stubHandler("billing/tiers", "list_tiers"))
|
||||
r.Get("/tiers/{tier_name}", stubHandler("billing/tiers/{name}", "get_tier"))
|
||||
r.Get("/limits", stubHandler("billing/limits", "get_limits"))
|
||||
r.Post("/checkout", stubHandler("billing/checkout", "checkout"))
|
||||
r.Post("/checkout/capture/{order_id}", stubHandler("billing/checkout/capture", "capture"))
|
||||
r.Post("/webhooks/commerce", stubHandler("billing/webhooks/commerce", "commerce_webhook"))
|
||||
r.Post("/sync", stubHandler("billing/sync POST", "sync_usage"))
|
||||
r.Get("/sync/status", stubHandler("billing/sync/status", "sync_status"))
|
||||
r.Get("/account", stubHandler("billing/account GET", "get_account"))
|
||||
r.Get("/account/subscriptions", stubHandler("billing/account/subscriptions", "account_subscriptions"))
|
||||
r.Get("/account/invoices", stubHandler("billing/account/invoices", "account_invoices"))
|
||||
r.Get("/account/payment-methods", stubHandler("billing/account/payment-methods", "account_methods"))
|
||||
r.Post("/account/sync", stubHandler("billing/account/sync", "account_sync"))
|
||||
r.Post("/cloud/authorize", stubHandler("billing/cloud/authorize", "cloud_authorize"))
|
||||
r.Get("/cloud/quota", stubHandler("billing/cloud/quota", "cloud_quota"))
|
||||
r.Post("/cloud/usage", stubHandler("billing/cloud/usage", "cloud_record_usage"))
|
||||
r.Get("/cloud/tiers", stubHandler("billing/cloud/tiers", "cloud_tiers"))
|
||||
})
|
||||
}
|
||||
|
||||
// MountZAP registers /v1/zap/* — discovery endpoints for the native
|
||||
// ZAP protocol. The wire-level RPC server lives in superbase; these
|
||||
// HTTP endpoints describe the schema + tools list to clients.
|
||||
//
|
||||
// We keep the path names but do NOT serve a .capnp file — public
|
||||
// surface is .zap only (brand policy).
|
||||
func MountZAP(r chi.Router, _ Deps) {
|
||||
r.Route("/zap", func(r chi.Router) {
|
||||
r.Get("/connect", stubHandler("zap/connect", "connect_info"))
|
||||
r.Get("/info", stubHandler("zap/info", "server_info"))
|
||||
r.Get("/tools", stubHandler("zap/tools", "list_tools"))
|
||||
r.Get("/resources", stubHandler("zap/resources", "list_resources"))
|
||||
r.Get("/health", stubHandler("zap/health", "zap_health"))
|
||||
r.Get("/schema", stubHandler("zap/schema", "serve_schema"))
|
||||
// schema.capnp endpoint is deliberately NOT mounted — ZAP-only public surface.
|
||||
})
|
||||
_ = chi.URLParam
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
bootnodezap "github.com/hanzoai/bootnode/zap/gen"
|
||||
)
|
||||
|
||||
// MountRPC registers /v1/rpc/{chain}[/{network}] — JSON-RPC proxy.
|
||||
func MountRPC(r chi.Router, d Deps) {
|
||||
r.Route("/rpc", func(r chi.Router) {
|
||||
r.Post("/{chain}", rpcHandler(d, ""))
|
||||
r.Post("/{chain}/{network}", rpcHandler(d, ""))
|
||||
})
|
||||
}
|
||||
|
||||
func rpcHandler(d Deps, _ string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
chain := chi.URLParam(r, "chain")
|
||||
network := chi.URLParam(r, "network")
|
||||
if network == "" {
|
||||
network = string(bootnodezap.NetworkMainnet)
|
||||
}
|
||||
|
||||
var raw struct {
|
||||
Method string `json:"method"`
|
||||
Params any `json:"params"`
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
if !readJSON(w, r, &raw) {
|
||||
return
|
||||
}
|
||||
// TODO(bootnode-port): port from bootnode/api/rpc/__init__.py:rpc_proxy —
|
||||
// upstream selection via web3.Pool, response caching via deps.Base,
|
||||
// compute-unit metering via deps.Commerce.RecordUsage.
|
||||
_, err := d.Service.RPC(r.Context(), bootnodezap.RPCRequest{
|
||||
Chain: bootnodezap.Chain(chain),
|
||||
Network: bootnodezap.Network(network),
|
||||
Method: raw.Method,
|
||||
ID: raw.ID,
|
||||
})
|
||||
if err != nil {
|
||||
serviceErr(w, err, "rpc/"+chain+"/"+network)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
|
||||
bootnodezap "github.com/hanzoai/bootnode/zap/gen"
|
||||
)
|
||||
|
||||
// ServiceDeps is the (smaller) dependency bundle the in-process ZAP
|
||||
// service implementation consumes. Distinct from the HTTP handler
|
||||
// Deps because the ZAP path doesn't need a Service handle to itself.
|
||||
type ServiceDeps struct {
|
||||
Logger *slog.Logger
|
||||
IAM IAMClient
|
||||
KMS KMSClient
|
||||
Base BaseClient
|
||||
O11y O11yClient
|
||||
Commerce CommerceClient
|
||||
}
|
||||
|
||||
// service is the canonical bootnodezap.Service implementation. The
|
||||
// HTTP handlers wrap it where they need typed access; superbase
|
||||
// hands the same instance to peer subsystems via the in-process ZAP
|
||||
// dispatcher (HIP-0106).
|
||||
//
|
||||
// Every method below is a stub that returns ErrNotImplemented with a
|
||||
// TODO(bootnode-port) tag pointing at the Python file we need to
|
||||
// port. We keep them on one struct so the interface contract stays
|
||||
// honest: when zapc regenerates bindings, the method set must match
|
||||
// or compile fails.
|
||||
type service struct {
|
||||
deps ServiceDeps
|
||||
}
|
||||
|
||||
// NewService builds the in-process Service implementation.
|
||||
func NewService(d ServiceDeps) bootnodezap.Service { return &service{deps: d} }
|
||||
|
||||
// ErrNotImplemented is the canonical "stub" error. Handlers convert
|
||||
// it to HTTP 501.
|
||||
var ErrNotImplemented = errors.New("bootnode: not implemented (port in progress)")
|
||||
|
||||
// ---- RPC ------------------------------------------------------------------
|
||||
|
||||
func (s *service) RPC(_ context.Context, _ bootnodezap.RPCRequest) (bootnodezap.RPCResponse, error) {
|
||||
// TODO(bootnode-port): port from bootnode/api/rpc/__init__.py — JSON-RPC
|
||||
// proxy fanned out across upstream nodes via web3.HTTPProvider equivalent
|
||||
// in api/web3/client.go.
|
||||
return bootnodezap.RPCResponse{}, ErrNotImplemented
|
||||
}
|
||||
|
||||
// ---- Tokens ---------------------------------------------------------------
|
||||
|
||||
func (s *service) TokenBalances(_ context.Context, _ bootnodezap.Chain, _ string) ([]bootnodezap.TokenBalance, error) {
|
||||
// TODO(bootnode-port): port from bootnode/api/tokens/__init__.py:balances
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
|
||||
func (s *service) TokenMetadata(_ context.Context, _ bootnodezap.Chain, _ string) (bootnodezap.TokenMetadata, error) {
|
||||
// TODO(bootnode-port): port from bootnode/api/tokens/__init__.py:metadata
|
||||
return bootnodezap.TokenMetadata{}, ErrNotImplemented
|
||||
}
|
||||
|
||||
// ---- NFTs -----------------------------------------------------------------
|
||||
|
||||
func (s *service) NFTMetadata(_ context.Context, _ bootnodezap.Chain, _, _ string) (bootnodezap.NFTMetadata, error) {
|
||||
// TODO(bootnode-port): port from bootnode/api/nfts/__init__.py:metadata
|
||||
return bootnodezap.NFTMetadata{}, ErrNotImplemented
|
||||
}
|
||||
|
||||
func (s *service) NFTCollection(_ context.Context, _ bootnodezap.Chain, _ string) (bootnodezap.NFTCollection, error) {
|
||||
// TODO(bootnode-port): port from bootnode/api/nfts/__init__.py:collection
|
||||
return bootnodezap.NFTCollection{}, ErrNotImplemented
|
||||
}
|
||||
|
||||
func (s *service) NFTsOwned(_ context.Context, _ bootnodezap.Chain, _ string) ([]bootnodezap.NFTMetadata, error) {
|
||||
// TODO(bootnode-port): port from bootnode/api/nfts/__init__.py:owned
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
|
||||
// ---- Transfers ------------------------------------------------------------
|
||||
|
||||
func (s *service) ListTransfers(_ context.Context, _ bootnodezap.Chain, _ string) ([]bootnodezap.Transfer, error) {
|
||||
// TODO(bootnode-port): port from bootnode/api/transfers/__init__.py:address
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
|
||||
// ---- Smart wallets --------------------------------------------------------
|
||||
|
||||
func (s *service) CreateSmartWallet(_ context.Context, _ bootnodezap.Chain, _ string) (bootnodezap.SmartWallet, error) {
|
||||
// TODO(bootnode-port): port from bootnode/api/wallets/__init__.py:create
|
||||
return bootnodezap.SmartWallet{}, ErrNotImplemented
|
||||
}
|
||||
|
||||
func (s *service) GetSmartWallet(_ context.Context, _ string) (bootnodezap.SmartWallet, error) {
|
||||
// TODO(bootnode-port): port from bootnode/api/wallets/__init__.py:get
|
||||
return bootnodezap.SmartWallet{}, ErrNotImplemented
|
||||
}
|
||||
|
||||
// ---- Bundler --------------------------------------------------------------
|
||||
|
||||
func (s *service) SendUserOp(_ context.Context, _ bootnodezap.Chain, _ bootnodezap.UserOp) (string, error) {
|
||||
// TODO(bootnode-port): port from bootnode/api/bundler/__init__.py:sendUserOperation
|
||||
return "", ErrNotImplemented
|
||||
}
|
||||
|
||||
func (s *service) GetUserOpReceipt(_ context.Context, _ bootnodezap.Chain, _ string) (bootnodezap.UserOpReceipt, error) {
|
||||
// TODO(bootnode-port): port from bootnode/api/bundler/__init__.py:getUserOperationReceipt
|
||||
return bootnodezap.UserOpReceipt{}, ErrNotImplemented
|
||||
}
|
||||
|
||||
// ---- Gas ------------------------------------------------------------------
|
||||
|
||||
func (s *service) GasPrices(_ context.Context, _ bootnodezap.Chain) (bootnodezap.GasPrices, error) {
|
||||
// TODO(bootnode-port): port from bootnode/api/gas/__init__.py:prices
|
||||
return bootnodezap.GasPrices{}, ErrNotImplemented
|
||||
}
|
||||
|
||||
func (s *service) SponsorUserOp(_ context.Context, _ bootnodezap.Chain, _ bootnodezap.UserOp, _ string) (string, error) {
|
||||
// TODO(bootnode-port): port from bootnode/api/gas/__init__.py:sponsor
|
||||
return "", ErrNotImplemented
|
||||
}
|
||||
|
||||
// ---- Webhooks -------------------------------------------------------------
|
||||
|
||||
func (s *service) CreateWebhook(_ context.Context, _ bootnodezap.Webhook) (bootnodezap.Webhook, error) {
|
||||
// TODO(bootnode-port): port from bootnode/api/webhooks/__init__.py:create
|
||||
return bootnodezap.Webhook{}, ErrNotImplemented
|
||||
}
|
||||
|
||||
func (s *service) ListWebhooks(_ context.Context, _ string) ([]bootnodezap.Webhook, error) {
|
||||
// TODO(bootnode-port): port from bootnode/api/webhooks/__init__.py:list
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
|
||||
func (s *service) DeleteWebhook(_ context.Context, _ string) (bool, error) {
|
||||
// TODO(bootnode-port): port from bootnode/api/webhooks/__init__.py:delete
|
||||
return false, ErrNotImplemented
|
||||
}
|
||||
|
||||
// ---- API keys / projects --------------------------------------------------
|
||||
|
||||
func (s *service) CreateAPIKey(_ context.Context, _, _, _ string, _ []string) (bootnodezap.CreateAPIKeyResult, error) {
|
||||
// TODO(bootnode-port): port from bootnode/api/auth_keys.py:create_api_key — MUST bcrypt the plaintext before persistence.
|
||||
return bootnodezap.CreateAPIKeyResult{}, ErrNotImplemented
|
||||
}
|
||||
|
||||
func (s *service) ListAPIKeys(_ context.Context, _ string) ([]bootnodezap.APIKey, error) {
|
||||
// TODO(bootnode-port): port from bootnode/api/auth_keys.py:list_api_keys
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
|
||||
func (s *service) RevokeAPIKey(_ context.Context, _, _ string) (bool, error) {
|
||||
// TODO(bootnode-port): port from bootnode/api/auth_keys.py:delete_api_key
|
||||
return false, ErrNotImplemented
|
||||
}
|
||||
|
||||
// ---- Fleets ---------------------------------------------------------------
|
||||
|
||||
func (s *service) CreateFleet(_ context.Context, _ bootnodezap.Fleet) (bootnodezap.Fleet, error) {
|
||||
// TODO(bootnode-port): port from bootnode/api/fleets/__init__.py:create
|
||||
return bootnodezap.Fleet{}, ErrNotImplemented
|
||||
}
|
||||
|
||||
func (s *service) ListFleets(_ context.Context, _ string) ([]bootnodezap.Fleet, error) {
|
||||
// TODO(bootnode-port): port from bootnode/api/fleets/__init__.py:list
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
|
||||
func (s *service) ScaleFleet(_ context.Context, _ string, _ int32) (bootnodezap.Fleet, error) {
|
||||
// TODO(bootnode-port): port from bootnode/api/fleets/__init__.py:scale
|
||||
return bootnodezap.Fleet{}, ErrNotImplemented
|
||||
}
|
||||
|
||||
func (s *service) DeleteFleet(_ context.Context, _ string) (bool, error) {
|
||||
// TODO(bootnode-port): port from bootnode/api/fleets/__init__.py:delete
|
||||
return false, ErrNotImplemented
|
||||
}
|
||||
|
||||
// ---- Nodes ----------------------------------------------------------------
|
||||
|
||||
func (s *service) DeployNode(_ context.Context, _ bootnodezap.Node) (bootnodezap.Node, error) {
|
||||
// TODO(bootnode-port): port from bootnode/api/nodes/__init__.py:create
|
||||
return bootnodezap.Node{}, ErrNotImplemented
|
||||
}
|
||||
|
||||
func (s *service) ListNodes(_ context.Context, _ string) ([]bootnodezap.Node, error) {
|
||||
// TODO(bootnode-port): port from bootnode/api/nodes/__init__.py:list
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
|
||||
func (s *service) StartNode(_ context.Context, _ string) (bool, error) {
|
||||
// TODO(bootnode-port): port from bootnode/api/nodes/__init__.py:start
|
||||
return false, ErrNotImplemented
|
||||
}
|
||||
|
||||
func (s *service) StopNode(_ context.Context, _ string) (bool, error) {
|
||||
// TODO(bootnode-port): port from bootnode/api/nodes/__init__.py:stop
|
||||
return false, ErrNotImplemented
|
||||
}
|
||||
|
||||
func (s *service) DeleteNode(_ context.Context, _ string) (bool, error) {
|
||||
// TODO(bootnode-port): port from bootnode/api/nodes/__init__.py:delete
|
||||
return false, ErrNotImplemented
|
||||
}
|
||||
|
||||
// ---- Usage ---------------------------------------------------------------
|
||||
|
||||
func (s *service) RecordUsage(_ context.Context, _ bootnodezap.UsageRecord) (bool, error) {
|
||||
// TODO(bootnode-port): port from bootnode/core/billing/sync.py:report_usage —
|
||||
// must call deps.Commerce.RecordUsage, never write payment data here.
|
||||
return false, ErrNotImplemented
|
||||
}
|
||||
|
||||
func (s *service) GetUsage(_ context.Context, _ string, _, _ int64) (bootnodezap.UsageSummary, error) {
|
||||
// TODO(bootnode-port): port from bootnode/api/billing/__init__.py:usage_summary
|
||||
return bootnodezap.UsageSummary{}, ErrNotImplemented
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// MountTokens registers /v1/tokens/{chain}/* — ERC-20 balances + metadata.
|
||||
func MountTokens(r chi.Router, _ Deps) {
|
||||
r.Route("/tokens", func(r chi.Router) {
|
||||
r.Get("/{chain}/balances/{address}", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/tokens/__init__.py:balances
|
||||
stub(w, "tokens/{chain}/balances/{address}")
|
||||
})
|
||||
r.Get("/{chain}/metadata/{contract}", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/tokens/__init__.py:metadata
|
||||
stub(w, "tokens/{chain}/metadata/{contract}")
|
||||
})
|
||||
r.Post("/{chain}", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/tokens/__init__.py:batch
|
||||
stub(w, "tokens/{chain} (batch)")
|
||||
})
|
||||
r.Post("/{chain}/{network}", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/tokens/__init__.py:batch_network
|
||||
stub(w, "tokens/{chain}/{network} (batch)")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// MountNFTs registers /v1/nfts/{chain}/* — collection + ownership + metadata.
|
||||
func MountNFTs(r chi.Router, _ Deps) {
|
||||
r.Route("/nfts", func(r chi.Router) {
|
||||
r.Get("/{chain}/owned/{address}", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/nfts/__init__.py:owned
|
||||
stub(w, "nfts/{chain}/owned/{address}")
|
||||
})
|
||||
r.Get("/{chain}/collection/{contract}", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/nfts/__init__.py:collection
|
||||
stub(w, "nfts/{chain}/collection/{contract}")
|
||||
})
|
||||
r.Get("/{chain}/metadata/{contract}/{token_id}", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/nfts/__init__.py:metadata
|
||||
stub(w, "nfts/{chain}/metadata/{contract}/{token_id}")
|
||||
})
|
||||
r.Post("/{chain}/refresh/{contract}/{token_id}", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/nfts/__init__.py:refresh_metadata —
|
||||
// enqueues an MQ job (was arq, target is deps.MQ subsystem).
|
||||
stub(w, "nfts/{chain}/refresh/{contract}/{token_id}")
|
||||
})
|
||||
_ = chi.URLParam // keep import live for symmetry with downstream handlers
|
||||
})
|
||||
}
|
||||
|
||||
// MountTransfers registers /v1/transfers/{chain}/* — historical transfers.
|
||||
func MountTransfers(r chi.Router, _ Deps) {
|
||||
r.Route("/transfers", func(r chi.Router) {
|
||||
r.Get("/{chain}/address/{address}", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/transfers/__init__.py:address
|
||||
stub(w, "transfers/{chain}/address/{address}")
|
||||
})
|
||||
r.Get("/{chain}/tx/{tx_hash}", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/transfers/__init__.py:tx
|
||||
stub(w, "transfers/{chain}/tx/{tx_hash}")
|
||||
})
|
||||
r.Post("/{chain}/send", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/transfers/__init__.py:send
|
||||
stub(w, "transfers/{chain}/send")
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// writeJSON renders v as a JSON response with status code.
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// readJSON decodes the request body into v. Returns 400 on failure.
|
||||
func readJSON(w http.ResponseWriter, r *http.Request, v any) bool {
|
||||
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, errBody{"invalid json: " + err.Error()})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type errBody struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// stub returns a 501 with the canonical port-in-progress message. It
|
||||
// is the one place HTTP handlers translate ErrNotImplemented from the
|
||||
// service into a wire-level response.
|
||||
func stub(w http.ResponseWriter, note string) {
|
||||
writeJSON(w, http.StatusNotImplemented, errBody{"not implemented: " + note})
|
||||
}
|
||||
|
||||
// serviceErr converts a typed service error into an HTTP response.
|
||||
// Today the only typed error is ErrNotImplemented; the function is
|
||||
// here as the seam where future error categories land.
|
||||
func serviceErr(w http.ResponseWriter, err error, note string) {
|
||||
if errors.Is(err, ErrNotImplemented) {
|
||||
stub(w, note)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusInternalServerError, errBody{err.Error()})
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// MountWallets registers /v1/wallets/* — ERC-4337 smart account API.
|
||||
func MountWallets(r chi.Router, _ Deps) {
|
||||
r.Route("/wallets", func(r chi.Router) {
|
||||
r.Post("/create", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/wallets/__init__.py:create —
|
||||
// keccak256 CREATE2 address (NOT sha256). See LLM.md "Phase 3" notes.
|
||||
stub(w, "wallets/create")
|
||||
})
|
||||
r.Get("/", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/wallets/__init__.py:list
|
||||
stub(w, "wallets list")
|
||||
})
|
||||
r.Get("/{address}", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/wallets/__init__.py:get
|
||||
stub(w, "wallets/{address}")
|
||||
})
|
||||
r.Post("/{address}/estimate", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/wallets/__init__.py:estimate
|
||||
stub(w, "wallets/{address}/estimate")
|
||||
})
|
||||
r.Get("/{address}/nonce", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/wallets/__init__.py:nonce
|
||||
stub(w, "wallets/{address}/nonce")
|
||||
})
|
||||
_ = chi.URLParam
|
||||
})
|
||||
}
|
||||
|
||||
// MountBundler registers /v1/bundler/{chain} — ERC-4337 RPC entrypoint.
|
||||
func MountBundler(r chi.Router, _ Deps) {
|
||||
r.Route("/bundler", func(r chi.Router) {
|
||||
r.Post("/{chain}", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/bundler/__init__.py:rpc —
|
||||
// implements eth_sendUserOperation / eth_estimateUserOperationGas / etc.
|
||||
stub(w, "bundler/{chain}")
|
||||
})
|
||||
r.Post("/{chain}/{network}", func(w http.ResponseWriter, _ *http.Request) {
|
||||
stub(w, "bundler/{chain}/{network}")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// MountGas registers /v1/gas/* — gas prices + paymaster policies.
|
||||
func MountGas(r chi.Router, _ Deps) {
|
||||
r.Route("/gas", func(r chi.Router) {
|
||||
r.Get("/{chain}/prices", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/gas/__init__.py:prices
|
||||
stub(w, "gas/{chain}/prices")
|
||||
})
|
||||
r.Post("/sponsor", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/gas/__init__.py:sponsor
|
||||
stub(w, "gas/sponsor")
|
||||
})
|
||||
r.Post("/estimate", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/gas/__init__.py:estimate
|
||||
stub(w, "gas/estimate")
|
||||
})
|
||||
r.Get("/policies", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/gas/__init__.py:list_policies
|
||||
stub(w, "gas/policies GET")
|
||||
})
|
||||
r.Post("/policies", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/gas/__init__.py:create_policy
|
||||
stub(w, "gas/policies POST")
|
||||
})
|
||||
r.Get("/policies/{id}", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/gas/__init__.py:get_policy
|
||||
stub(w, "gas/policies/{id}")
|
||||
})
|
||||
r.Delete("/policies/{id}", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/gas/__init__.py:delete_policy
|
||||
stub(w, "gas/policies/{id} DELETE")
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// MountWebhooks registers /v1/webhooks/* — customer webhook subscription
|
||||
// management. Delivery itself runs in the MQ subsystem (was Python arq).
|
||||
func MountWebhooks(r chi.Router, _ Deps) {
|
||||
r.Route("/webhooks", func(r chi.Router) {
|
||||
r.Post("/", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/webhooks/__init__.py:create
|
||||
stub(w, "webhooks POST")
|
||||
})
|
||||
r.Get("/", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/webhooks/__init__.py:list
|
||||
stub(w, "webhooks GET")
|
||||
})
|
||||
r.Get("/{webhook_id}", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/webhooks/__init__.py:get
|
||||
stub(w, "webhooks/{id}")
|
||||
})
|
||||
r.Patch("/{webhook_id}", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/webhooks/__init__.py:update
|
||||
stub(w, "webhooks/{id} PATCH")
|
||||
})
|
||||
r.Delete("/{webhook_id}", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/webhooks/__init__.py:delete
|
||||
stub(w, "webhooks/{id} DELETE")
|
||||
})
|
||||
r.Get("/{webhook_id}/deliveries", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/webhooks/__init__.py:deliveries
|
||||
stub(w, "webhooks/{id}/deliveries")
|
||||
})
|
||||
r.Post("/{webhook_id}/test", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO(bootnode-port): port from bootnode/api/webhooks/__init__.py:test
|
||||
stub(w, "webhooks/{id}/test")
|
||||
})
|
||||
_ = chi.URLParam
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// Package middleware holds the chi middleware Bootnode mounts.
|
||||
//
|
||||
// All identity decisions go through deps.IAM — Bootnode never decodes
|
||||
// JWTs itself. Per the Hanzo header convention (LLM.md), incoming
|
||||
// X-Org-Id / X-User-Id / X-User-Email are TRUSTED only when minted by
|
||||
// the gateway from a validated JWT. When Bootnode runs standalone it
|
||||
// derives them from the Authorization bearer via deps.IAM.
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ctxKey is unexported so external packages can't accidentally clobber
|
||||
// our context values.
|
||||
type ctxKey int
|
||||
|
||||
const (
|
||||
ctxOrgID ctxKey = iota
|
||||
ctxUserID
|
||||
ctxEmail
|
||||
)
|
||||
|
||||
// IAMVerifier matches api.Deps.IAM but lets this package avoid an
|
||||
// import cycle.
|
||||
type IAMVerifier interface {
|
||||
VerifyJWT(ctx context.Context, bearer string) (orgID, userID, email string, err error)
|
||||
}
|
||||
|
||||
// AuthOptional wires identity into request context when the
|
||||
// Authorization header parses; missing/invalid tokens fall through
|
||||
// (handlers that require auth call MustAuth on the context).
|
||||
func AuthOptional(iam IAMVerifier) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
// 1) Gateway-trusted headers (per HTTP Header Convention
|
||||
// 2026-03-27): only honored when the upstream gateway
|
||||
// has already validated the JWT. We trust them iff IAM
|
||||
// is nil (split-deploy behind gateway) or the bearer
|
||||
// matches.
|
||||
if orgID := r.Header.Get("X-Org-Id"); orgID != "" && iam == nil {
|
||||
ctx = context.WithValue(ctx, ctxOrgID, orgID)
|
||||
ctx = context.WithValue(ctx, ctxUserID, r.Header.Get("X-User-Id"))
|
||||
ctx = context.WithValue(ctx, ctxEmail, r.Header.Get("X-User-Email"))
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
|
||||
// 2) Authorization: Bearer ... → IAM verify.
|
||||
if iam != nil {
|
||||
if bearer := bearerToken(r); bearer != "" {
|
||||
orgID, userID, email, err := iam.VerifyJWT(ctx, bearer)
|
||||
if err == nil {
|
||||
ctx = context.WithValue(ctx, ctxOrgID, orgID)
|
||||
ctx = context.WithValue(ctx, ctxUserID, userID)
|
||||
ctx = context.WithValue(ctx, ctxEmail, email)
|
||||
}
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// OrgID returns the org slug attached by AuthOptional, or "".
|
||||
func OrgID(ctx context.Context) string {
|
||||
if v, ok := ctx.Value(ctxOrgID).(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// UserID returns the user ID attached by AuthOptional, or "".
|
||||
func UserID(ctx context.Context) string {
|
||||
if v, ok := ctx.Value(ctxUserID).(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Email returns the user email attached by AuthOptional, or "".
|
||||
func Email(ctx context.Context) string {
|
||||
if v, ok := ctx.Value(ctxEmail).(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// RequireAuth wraps a handler so it 401s when no org is attached.
|
||||
func RequireAuth(h http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if OrgID(r.Context()) == "" {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
h(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func bearerToken(r *http.Request) string {
|
||||
h := r.Header.Get("Authorization")
|
||||
const p = "Bearer "
|
||||
if !strings.HasPrefix(h, p) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(h[len(p):])
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RateLimit is a per-org sliding-window limiter. It exists as a stub
|
||||
// so handlers can compose it today; the production version reads
|
||||
// per-org rate limits from the api_keys table via deps.Base.
|
||||
//
|
||||
// TODO(bootnode-port): replace with the FastAPI rate-limit equivalent
|
||||
// (Redis token bucket via deps.MQ or in-process LRU). See
|
||||
// api/bootnode/api/deps.py:rate_limit.
|
||||
type RateLimit struct {
|
||||
mu sync.Mutex
|
||||
buckets map[string]*bucket
|
||||
limit int
|
||||
window time.Duration
|
||||
}
|
||||
|
||||
type bucket struct {
|
||||
tokens int
|
||||
resetAt time.Time
|
||||
}
|
||||
|
||||
// NewRateLimit creates a per-key limiter (limit requests / window).
|
||||
func NewRateLimit(limit int, window time.Duration) *RateLimit {
|
||||
return &RateLimit{
|
||||
buckets: make(map[string]*bucket),
|
||||
limit: limit,
|
||||
window: window,
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap returns a chi middleware that keys off the org slug attached
|
||||
// to the request context.
|
||||
func (rl *RateLimit) Wrap(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
key := OrgID(r.Context())
|
||||
if key == "" {
|
||||
key = r.RemoteAddr
|
||||
}
|
||||
if !rl.allow(key) {
|
||||
http.Error(w, "rate limited", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (rl *RateLimit) allow(key string) bool {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
b, ok := rl.buckets[key]
|
||||
if !ok || now.After(b.resetAt) {
|
||||
rl.buckets[key] = &bucket{tokens: rl.limit - 1, resetAt: now.Add(rl.window)}
|
||||
return true
|
||||
}
|
||||
if b.tokens <= 0 {
|
||||
return false
|
||||
}
|
||||
b.tokens--
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// O11yRecorder matches api.Deps.O11y but lets this package avoid an
|
||||
// import cycle.
|
||||
type O11yRecorder interface {
|
||||
RecordRequest(ctx context.Context, route string, status int, durationMs int64)
|
||||
}
|
||||
|
||||
// Telemetry records every request through deps.O11y. Safe with a nil
|
||||
// recorder — the middleware becomes a no-op pass-through.
|
||||
func Telemetry(rec O11yRecorder) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
if rec == nil {
|
||||
return next
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
|
||||
next.ServeHTTP(sw, r)
|
||||
rec.RecordRequest(r.Context(), r.URL.Path, sw.status, time.Since(start).Milliseconds())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type statusWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (s *statusWriter) WriteHeader(code int) {
|
||||
s.status = code
|
||||
s.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/hanzoai/bootnode/api/handlers"
|
||||
"github.com/hanzoai/bootnode/api/middleware"
|
||||
bootnodezap "github.com/hanzoai/bootnode/zap/gen"
|
||||
)
|
||||
|
||||
// Mount registers every Bootnode HTTP route + ZAP service onto r,
|
||||
// using deps for cross-subsystem calls. This is the HIP-0106
|
||||
// subsystem contract: superbase calls Mount on the unified binary's
|
||||
// root mux, cmd/bootnode calls it on a fresh chi.Mux.
|
||||
//
|
||||
// Routes are grouped by FastAPI parity:
|
||||
//
|
||||
// GET /health liveness
|
||||
// GET /ready readiness (deps reachable)
|
||||
//
|
||||
// POST /v1/auth/register local-dev auth (TODO: IAM-only in prod)
|
||||
// POST /v1/auth/login
|
||||
// GET /v1/auth/me
|
||||
// POST /v1/auth/oauth/callback IAM OAuth code exchange
|
||||
//
|
||||
// GET /v1/chains list supported chains
|
||||
// GET /v1/chains/{chain} chain details
|
||||
//
|
||||
// POST /v1/rpc/{chain} JSON-RPC proxy
|
||||
// POST /v1/rpc/{chain}/{network}
|
||||
//
|
||||
// GET /v1/tokens/{chain}/balances/{address}
|
||||
// GET /v1/tokens/{chain}/metadata/{contract}
|
||||
// POST /v1/tokens/{chain} (batched)
|
||||
// POST /v1/tokens/{chain}/{network}
|
||||
//
|
||||
// GET /v1/nfts/{chain}/owned/{address}
|
||||
// GET /v1/nfts/{chain}/collection/{contract}
|
||||
// GET /v1/nfts/{chain}/metadata/{contract}/{tokenId}
|
||||
// POST /v1/nfts/{chain}/refresh/{contract}/{tokenId}
|
||||
//
|
||||
// GET /v1/transfers/{chain}/address/{address}
|
||||
// GET /v1/transfers/{chain}/tx/{txHash}
|
||||
// POST /v1/transfers/{chain}/send
|
||||
//
|
||||
// POST /v1/wallets/create
|
||||
// GET /v1/wallets
|
||||
// GET /v1/wallets/{address}
|
||||
// POST /v1/wallets/{address}/estimate
|
||||
// GET /v1/wallets/{address}/nonce
|
||||
//
|
||||
// POST /v1/bundler/{chain} ERC-4337 RPC
|
||||
// POST /v1/bundler/{chain}/{network}
|
||||
//
|
||||
// GET /v1/gas/{chain}/prices
|
||||
// POST /v1/gas/sponsor
|
||||
// POST /v1/gas/estimate
|
||||
// GET /v1/gas/policies
|
||||
// POST /v1/gas/policies
|
||||
// GET /v1/gas/policies/{id}
|
||||
// DELETE /v1/gas/policies/{id}
|
||||
//
|
||||
// POST /v1/webhooks
|
||||
// GET /v1/webhooks
|
||||
// GET /v1/webhooks/{id}
|
||||
// PATCH /v1/webhooks/{id}
|
||||
// DELETE /v1/webhooks/{id}
|
||||
// GET /v1/webhooks/{id}/deliveries
|
||||
// POST /v1/webhooks/{id}/test
|
||||
//
|
||||
// POST /v1/auth-keys/projects
|
||||
// GET /v1/auth-keys/projects/{id}
|
||||
// POST /v1/auth-keys/keys
|
||||
// GET /v1/auth-keys/keys
|
||||
// DELETE /v1/auth-keys/keys/{id}
|
||||
//
|
||||
// (fleets, nodes, infra, team, networks, o11y, chat, zap) — same
|
||||
// parity, see handlers/*.go for the per-package router.
|
||||
func Mount(r chi.Router, deps Deps) error {
|
||||
if deps.Logger == nil {
|
||||
return errors.New("bootnode: deps.Logger is required")
|
||||
}
|
||||
|
||||
// In-process ZAP service. zapc's generated dispatcher would
|
||||
// register this with the local dispatcher when superbase wires it;
|
||||
// the hand-rolled stub just holds the Service implementation.
|
||||
impl := handlers.NewService(handlers.ServiceDeps{
|
||||
Logger: deps.Logger,
|
||||
IAM: deps.IAM,
|
||||
KMS: deps.KMS,
|
||||
Base: deps.Base,
|
||||
O11y: deps.O11y,
|
||||
Commerce: deps.Commerce,
|
||||
})
|
||||
_ = bootnodezap.NewServer(impl) // hand to superbase ZAP dispatcher
|
||||
|
||||
hdeps := handlers.Deps{
|
||||
Logger: deps.Logger,
|
||||
Service: impl,
|
||||
IAM: deps.IAM,
|
||||
KMS: deps.KMS,
|
||||
Base: deps.Base,
|
||||
O11y: deps.O11y,
|
||||
Commerce: deps.Commerce,
|
||||
}
|
||||
|
||||
// Health endpoints are mounted at the root — superbase aggregates
|
||||
// them but Bootnode-as-standalone needs them addressable too.
|
||||
r.Get("/health", handlers.Health(hdeps))
|
||||
r.Get("/ready", handlers.Ready(hdeps))
|
||||
|
||||
r.Route("/v1", func(r chi.Router) {
|
||||
r.Use(middleware.Telemetry(deps.O11y))
|
||||
r.Use(middleware.AuthOptional(deps.IAM))
|
||||
|
||||
handlers.MountAuth(r, hdeps)
|
||||
handlers.MountAuthKeys(r, hdeps)
|
||||
handlers.MountChains(r, hdeps)
|
||||
handlers.MountRPC(r, hdeps)
|
||||
handlers.MountTokens(r, hdeps)
|
||||
handlers.MountNFTs(r, hdeps)
|
||||
handlers.MountTransfers(r, hdeps)
|
||||
handlers.MountWallets(r, hdeps)
|
||||
handlers.MountBundler(r, hdeps)
|
||||
handlers.MountGas(r, hdeps)
|
||||
handlers.MountWebhooks(r, hdeps)
|
||||
handlers.MountFleets(r, hdeps)
|
||||
handlers.MountNodes(r, hdeps)
|
||||
handlers.MountInfra(r, hdeps)
|
||||
handlers.MountTeam(r, hdeps)
|
||||
handlers.MountNetworks(r, hdeps)
|
||||
handlers.MountObservability(r, hdeps)
|
||||
handlers.MountChat(r, hdeps)
|
||||
handlers.MountBilling(r, hdeps)
|
||||
handlers.MountZAP(r, hdeps)
|
||||
})
|
||||
|
||||
// 404 for unknown paths inside our prefix range, leaving the rest
|
||||
// to superbase's other subsystems.
|
||||
r.NotFound(func(w http.ResponseWriter, req *http.Request) {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Package store is the per-tenant persistence layer. Every method
|
||||
// takes an orgID and resolves the per-org SQLite handle via
|
||||
// deps.Base.Open(ctx, orgID, "bootnode") per HIP-0302.
|
||||
//
|
||||
// TODO(bootnode-port): when deps.Base lands as a typed client
|
||||
// (`basezap.Client`), the `any` here becomes the typed handle and
|
||||
// these methods grow real CRUD bodies.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
bootnodezap "github.com/hanzoai/bootnode/zap/gen"
|
||||
)
|
||||
|
||||
// BaseClient is the per-tenant SQLite opener (HIP-0302).
|
||||
type BaseClient interface {
|
||||
Open(ctx context.Context, orgID, namespace string) (any, error)
|
||||
}
|
||||
|
||||
// APIKeyStore persists API keys + projects. Production stores bcrypt
|
||||
// hashes, never plaintext (Hanzo CLAUDE.md hard rule).
|
||||
type APIKeyStore struct {
|
||||
base BaseClient
|
||||
}
|
||||
|
||||
// NewAPIKeyStore builds the per-tenant API-key store.
|
||||
func NewAPIKeyStore(b BaseClient) *APIKeyStore { return &APIKeyStore{base: b} }
|
||||
|
||||
// ErrNotFound is returned when a row does not exist.
|
||||
var ErrNotFound = errors.New("store: not found")
|
||||
|
||||
// Create writes a new API key row, hashing the plaintext with bcrypt
|
||||
// before persistence.
|
||||
//
|
||||
// TODO(bootnode-port): port from bootnode/api/auth_keys.py:create_api_key —
|
||||
// use golang.org/x/crypto/bcrypt.GenerateFromPassword with cost 12.
|
||||
func (s *APIKeyStore) Create(ctx context.Context, k bootnodezap.APIKey, plaintext string) error {
|
||||
_ = ctx
|
||||
_ = k
|
||||
_ = plaintext
|
||||
return errors.New("store.APIKeyStore.Create: not implemented")
|
||||
}
|
||||
|
||||
// List returns every API key for an org.
|
||||
//
|
||||
// TODO(bootnode-port): port from bootnode/api/auth_keys.py:list_api_keys
|
||||
func (s *APIKeyStore) List(ctx context.Context, orgID string) ([]bootnodezap.APIKey, error) {
|
||||
_ = ctx
|
||||
_ = orgID
|
||||
return nil, errors.New("store.APIKeyStore.List: not implemented")
|
||||
}
|
||||
|
||||
// Revoke marks an API key as deleted (soft-delete to preserve audit).
|
||||
//
|
||||
// TODO(bootnode-port): port from bootnode/api/auth_keys.py:delete_api_key
|
||||
func (s *APIKeyStore) Revoke(ctx context.Context, orgID, id string) error {
|
||||
_ = ctx
|
||||
_ = orgID
|
||||
_ = id
|
||||
return errors.New("store.APIKeyStore.Revoke: not implemented")
|
||||
}
|
||||
|
||||
// VerifyPlaintext bcrypt-compares the supplied plaintext against the
|
||||
// stored hash. Returns ErrNotFound if no row matches.
|
||||
//
|
||||
// TODO(bootnode-port): port from bootnode/api/auth_keys.py:verify_key
|
||||
func (s *APIKeyStore) VerifyPlaintext(ctx context.Context, plaintext string) (bootnodezap.APIKey, error) {
|
||||
_ = ctx
|
||||
_ = plaintext
|
||||
return bootnodezap.APIKey{}, ErrNotFound
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
bootnodezap "github.com/hanzoai/bootnode/zap/gen"
|
||||
)
|
||||
|
||||
// ProjectStore persists per-org Project rows.
|
||||
type ProjectStore struct {
|
||||
base BaseClient
|
||||
}
|
||||
|
||||
// NewProjectStore builds the per-tenant project store.
|
||||
func NewProjectStore(b BaseClient) *ProjectStore { return &ProjectStore{base: b} }
|
||||
|
||||
// Create inserts a Project.
|
||||
//
|
||||
// TODO(bootnode-port): port from bootnode/api/auth_keys.py:create_project
|
||||
func (s *ProjectStore) Create(ctx context.Context, p bootnodezap.Project) error {
|
||||
_ = ctx
|
||||
_ = p
|
||||
return errors.New("store.ProjectStore.Create: not implemented")
|
||||
}
|
||||
|
||||
// Get reads one project by id.
|
||||
//
|
||||
// TODO(bootnode-port): port from bootnode/api/auth_keys.py:get_project
|
||||
func (s *ProjectStore) Get(ctx context.Context, orgID, id string) (bootnodezap.Project, error) {
|
||||
_ = ctx
|
||||
_ = orgID
|
||||
_ = id
|
||||
return bootnodezap.Project{}, ErrNotFound
|
||||
}
|
||||
|
||||
// List returns every project for an org.
|
||||
//
|
||||
// TODO(bootnode-port): port from bootnode/api/auth_keys.py:list_projects
|
||||
func (s *ProjectStore) List(ctx context.Context, orgID string) ([]bootnodezap.Project, error) {
|
||||
_ = ctx
|
||||
_ = orgID
|
||||
return nil, errors.New("store.ProjectStore.List: not implemented")
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
bootnodezap "github.com/hanzoai/bootnode/zap/gen"
|
||||
)
|
||||
|
||||
// UsageStore persists per-org/per-project compute-unit accounting.
|
||||
// Hot-path counters live in deps.MQ (Redis-equivalent), durable
|
||||
// accounting lives here in per-tenant SQLite, and roll-ups sync to
|
||||
// Commerce via deps.Commerce.RecordUsage on a timer.
|
||||
type UsageStore struct {
|
||||
base BaseClient
|
||||
}
|
||||
|
||||
// NewUsageStore builds the per-tenant usage store.
|
||||
func NewUsageStore(b BaseClient) *UsageStore { return &UsageStore{base: b} }
|
||||
|
||||
// Append writes one billable call.
|
||||
//
|
||||
// TODO(bootnode-port): port from bootnode/core/billing/tracker.py
|
||||
func (s *UsageStore) Append(ctx context.Context, r bootnodezap.UsageRecord) error {
|
||||
_ = ctx
|
||||
_ = r
|
||||
return errors.New("store.UsageStore.Append: not implemented")
|
||||
}
|
||||
|
||||
// Summary aggregates usage over (start, end).
|
||||
//
|
||||
// TODO(bootnode-port): port from bootnode/api/billing/__init__.py:usage_summary
|
||||
func (s *UsageStore) Summary(ctx context.Context, orgID string, start, end int64) (bootnodezap.UsageSummary, error) {
|
||||
_ = ctx
|
||||
_ = orgID
|
||||
_ = start
|
||||
_ = end
|
||||
return bootnodezap.UsageSummary{}, errors.New("store.UsageStore.Summary: not implemented")
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Package web3 wraps chain configuration + multi-chain RPC plumbing.
|
||||
//
|
||||
// The HTTP layer asks this package for canonical chain data; the RPC
|
||||
// proxy fans out through a pool defined in client.go. luxfi/* packages
|
||||
// are preferred for any chain library imports (see ~/.claude/CLAUDE.md).
|
||||
package web3
|
||||
|
||||
// ChainInfo is the canonical per-chain record exposed at /v1/chains.
|
||||
// Mirrors bootnode/core/chains/registry.py.
|
||||
type ChainInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ChainID int64 `json:"chainId"`
|
||||
IsEVM bool `json:"isEvm"`
|
||||
Currency string `json:"currency"`
|
||||
Explorer string `json:"explorer"`
|
||||
}
|
||||
|
||||
// allChains is the initial static set; production reads from
|
||||
// deps.Base ("registry" table) so customers can add custom chains.
|
||||
//
|
||||
// TODO(bootnode-port): hydrate from bootnode/core/chains/registry.py
|
||||
// (CHAIN_REGISTRY) at startup; allow per-org overrides.
|
||||
var allChains = []ChainInfo{
|
||||
{ID: "ethereum", Name: "Ethereum", ChainID: 1, IsEVM: true, Currency: "ETH", Explorer: "https://etherscan.io"},
|
||||
{ID: "polygon", Name: "Polygon", ChainID: 137, IsEVM: true, Currency: "MATIC", Explorer: "https://polygonscan.com"},
|
||||
{ID: "arbitrum", Name: "Arbitrum One", ChainID: 42161, IsEVM: true, Currency: "ETH", Explorer: "https://arbiscan.io"},
|
||||
{ID: "optimism", Name: "Optimism", ChainID: 10, IsEVM: true, Currency: "ETH", Explorer: "https://optimistic.etherscan.io"},
|
||||
{ID: "base", Name: "Base", ChainID: 8453, IsEVM: true, Currency: "ETH", Explorer: "https://basescan.org"},
|
||||
{ID: "bsc", Name: "BNB Chain", ChainID: 56, IsEVM: true, Currency: "BNB", Explorer: "https://bscscan.com"},
|
||||
{ID: "avalanche", Name: "Avalanche C-Chain", ChainID: 43114, IsEVM: true, Currency: "AVAX", Explorer: "https://snowtrace.io"},
|
||||
{ID: "lux", Name: "Lux Network", ChainID: 96369, IsEVM: true, Currency: "LUX", Explorer: "https://explore.lux.network"},
|
||||
}
|
||||
|
||||
// AllChains returns every chain Bootnode currently knows about.
|
||||
func AllChains() []ChainInfo { return append([]ChainInfo(nil), allChains...) }
|
||||
|
||||
// ChainByName looks up one chain by its kebab-cased ID.
|
||||
func ChainByName(id string) (ChainInfo, bool) {
|
||||
for _, c := range allChains {
|
||||
if c.ID == id {
|
||||
return c, true
|
||||
}
|
||||
}
|
||||
return ChainInfo{}, false
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package web3
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// Pool is the multi-chain RPC client surface. Each chain has one or
|
||||
// more upstream URLs; Pool.Call selects an upstream, applies
|
||||
// per-method caching via deps.Base, and meters compute units via
|
||||
// deps.Commerce.
|
||||
//
|
||||
// TODO(bootnode-port): port from bootnode/core/chains/rpc.py — the
|
||||
// production version maintains per-upstream health, sticky-session
|
||||
// routing for `eth_getLogs`, and exponential backoff. Prefer
|
||||
// `luxfi/geth` or `luxfi/ethclient` over upstream go-ethereum.
|
||||
type Pool interface {
|
||||
Call(ctx context.Context, chain string, method string, params any) (any, error)
|
||||
}
|
||||
|
||||
// ErrNoUpstream is returned when no upstream URL is configured for a
|
||||
// chain.
|
||||
var ErrNoUpstream = errors.New("web3: no upstream configured for chain")
|
||||
|
||||
// NewPool returns a placeholder pool that always errors. Wired by
|
||||
// superbase once the real implementation lands.
|
||||
func NewPool(_ map[string][]string) Pool { return &emptyPool{} }
|
||||
|
||||
type emptyPool struct{}
|
||||
|
||||
func (e *emptyPool) Call(_ context.Context, _ string, _ string, _ any) (any, error) {
|
||||
return nil, ErrNoUpstream
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// bootnode is the standalone process entrypoint for the Bootnode
|
||||
// subsystem. It calls api.Mount on a fresh chi.Mux and serves HTTP
|
||||
// on :8100. When folded into superbase per HIP-0106, the same
|
||||
// api.Mount is invoked on superbase's root mux — this binary becomes
|
||||
// a development convenience, not a production deploy target.
|
||||
//
|
||||
// Standalone usage:
|
||||
//
|
||||
// go run ./cmd/bootnode
|
||||
// BOOTNODE_ADDR=:9001 go run ./cmd/bootnode
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
chimw "github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"github.com/hanzoai/bootnode/api"
|
||||
)
|
||||
|
||||
func main() {
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stderr, nil))
|
||||
|
||||
addr := os.Getenv("BOOTNODE_ADDR")
|
||||
if addr == "" {
|
||||
addr = ":8100"
|
||||
}
|
||||
|
||||
r := chi.NewMux()
|
||||
r.Use(chimw.RequestID)
|
||||
r.Use(chimw.RealIP)
|
||||
r.Use(chimw.Recoverer)
|
||||
|
||||
deps := api.Deps{
|
||||
Brand: brandFromEnv(),
|
||||
Logger: logger,
|
||||
// IAM/KMS/Base/O11y/Commerce are nil in the standalone shim;
|
||||
// handlers fail closed and emit 501s. In production superbase
|
||||
// wires the real in-process ZAP clients.
|
||||
}
|
||||
|
||||
if err := api.Mount(r, deps); err != nil {
|
||||
logger.Error("mount failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
logger.Info("bootnode listening", "addr", addr, "brand", deps.Brand)
|
||||
srv := &http.Server{Addr: addr, Handler: r}
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
logger.Error("serve failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func brandFromEnv() string {
|
||||
if v := os.Getenv("BOOTNODE_BRAND"); v != "" {
|
||||
return v
|
||||
}
|
||||
return "hanzo"
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
# Bootnode: Python → Go port map
|
||||
|
||||
Source of truth: `~/work/hanzo/bootnode/api/bootnode/api/*` (FastAPI).
|
||||
|
||||
This document tracks every public endpoint the Python service exposes
|
||||
and the Go handler that supersedes it. "Status" is one of:
|
||||
|
||||
- **stub** — Go handler registered, returns 501 with TODO
|
||||
- **port** — handler body ported, tests pending
|
||||
- **prod** — ported, tested, parity-verified against Python
|
||||
|
||||
The Go-side handler signatures all live under
|
||||
`api/handlers/<group>.go`. The typed service shape (`bootnodezap.Service`)
|
||||
lives in `zap/gen/bootnode.go`.
|
||||
|
||||
## /v1/auth (Authentication)
|
||||
|
||||
| Method | Path | Python | Go | Status |
|
||||
|--------|------------------------------|-------------------------------------|---------------------------------|--------|
|
||||
| POST | /v1/auth/register | api/auth/__init__.py:register | handlers/auth.go | stub |
|
||||
| POST | /v1/auth/login | api/auth/__init__.py:login | handlers/auth.go | stub |
|
||||
| GET | /v1/auth/me | api/auth/__init__.py:me | handlers/auth.go | stub |
|
||||
| POST | /v1/auth/oauth/callback | api/auth/__init__.py:oauth_callback | handlers/auth.go | stub |
|
||||
|
||||
bcrypt cost 12 required on `register`. In production every authenticated
|
||||
endpoint flows through `deps.IAM.VerifyJWT`; the local register/login
|
||||
paths are dev-only.
|
||||
|
||||
## /v1/auth-keys (API keys + projects)
|
||||
|
||||
| Method | Path | Python | Go | Status |
|
||||
|--------|---------------------------------|------------------------------|----------------------------------------------------|--------|
|
||||
| POST | /v1/auth-keys/projects | api/auth_keys.py:create_project | handlers/authkeys.go | stub |
|
||||
| GET | /v1/auth-keys/projects/{id} | api/auth_keys.py:get_project | handlers/authkeys.go | stub |
|
||||
| POST | /v1/auth-keys/keys | api/auth_keys.py:create_api_key | handlers/authkeys.go (calls Service.CreateAPIKey) | stub |
|
||||
| GET | /v1/auth-keys/keys | api/auth_keys.py:list_api_keys | handlers/authkeys.go | stub |
|
||||
| DELETE | /v1/auth-keys/keys/{id} | api/auth_keys.py:delete_api_key | handlers/authkeys.go | stub |
|
||||
|
||||
Plaintext returned ONCE on creation. Storage is bcrypt hash via
|
||||
`store.APIKeyStore`.
|
||||
|
||||
## /v1/chains
|
||||
|
||||
| Method | Path | Python | Go | Status |
|
||||
|--------|---------------------|------------------------------|---------------------|--------|
|
||||
| GET | /v1/chains | api/chains.py:list_chains | handlers/chains.go | port |
|
||||
| GET | /v1/chains/{chain} | api/chains.py:get_chain | handlers/chains.go | port |
|
||||
|
||||
Backed by `web3.AllChains()` / `web3.ChainByName()`. Production hydrates
|
||||
from `core/chains/registry.py` table on startup.
|
||||
|
||||
## /v1/rpc (multi-chain JSON-RPC proxy)
|
||||
|
||||
| Method | Path | Python | Go | Status |
|
||||
|--------|---------------------------------------|-------------------------|-------------------|--------|
|
||||
| POST | /v1/rpc/{chain} | api/rpc/__init__.py | handlers/rpc.go | stub |
|
||||
| POST | /v1/rpc/{chain}/{network} | api/rpc/__init__.py | handlers/rpc.go | stub |
|
||||
|
||||
Forwards through `web3.Pool.Call`; meters compute units via
|
||||
`deps.Commerce.RecordUsage`. Cache via `deps.Base`.
|
||||
|
||||
## /v1/tokens
|
||||
|
||||
| Method | Path | Python | Go | Status |
|
||||
|--------|--------------------------------------------|---------------------------------|---------------------|--------|
|
||||
| GET | /v1/tokens/{chain}/balances/{address} | api/tokens/__init__.py:balances | handlers/tokens.go | stub |
|
||||
| GET | /v1/tokens/{chain}/metadata/{contract} | api/tokens/__init__.py:metadata | handlers/tokens.go | stub |
|
||||
| POST | /v1/tokens/{chain} | api/tokens/__init__.py:batch | handlers/tokens.go | stub |
|
||||
| POST | /v1/tokens/{chain}/{network} | api/tokens/__init__.py:batch | handlers/tokens.go | stub |
|
||||
|
||||
## /v1/nfts
|
||||
|
||||
| Method | Path | Python | Go | Status |
|
||||
|--------|-----------------------------------------------------|-------------------------------------|---------------------|--------|
|
||||
| GET | /v1/nfts/{chain}/owned/{address} | api/nfts/__init__.py:owned | handlers/tokens.go | stub |
|
||||
| GET | /v1/nfts/{chain}/collection/{contract} | api/nfts/__init__.py:collection | handlers/tokens.go | stub |
|
||||
| GET | /v1/nfts/{chain}/metadata/{contract}/{token_id} | api/nfts/__init__.py:metadata | handlers/tokens.go | stub |
|
||||
| POST | /v1/nfts/{chain}/refresh/{contract}/{token_id} | api/nfts/__init__.py:refresh | handlers/tokens.go | stub |
|
||||
|
||||
Refresh enqueues an MQ job (Python arq → `deps.MQ`).
|
||||
|
||||
## /v1/transfers
|
||||
|
||||
| Method | Path | Python | Go | Status |
|
||||
|--------|---------------------------------------------|-----------------------------------|---------------------|--------|
|
||||
| GET | /v1/transfers/{chain}/address/{address} | api/transfers/__init__.py:address | handlers/tokens.go | stub |
|
||||
| GET | /v1/transfers/{chain}/tx/{tx_hash} | api/transfers/__init__.py:tx | handlers/tokens.go | stub |
|
||||
| POST | /v1/transfers/{chain}/send | api/transfers/__init__.py:send | handlers/tokens.go | stub |
|
||||
|
||||
## /v1/wallets (ERC-4337 smart accounts)
|
||||
|
||||
| Method | Path | Python | Go | Status |
|
||||
|--------|---------------------------------------|-------------------------------------|---------------------|--------|
|
||||
| POST | /v1/wallets/create | api/wallets/__init__.py:create | handlers/wallet.go | stub |
|
||||
| GET | /v1/wallets | api/wallets/__init__.py:list | handlers/wallet.go | stub |
|
||||
| GET | /v1/wallets/{address} | api/wallets/__init__.py:get | handlers/wallet.go | stub |
|
||||
| POST | /v1/wallets/{address}/estimate | api/wallets/__init__.py:estimate | handlers/wallet.go | stub |
|
||||
| GET | /v1/wallets/{address}/nonce | api/wallets/__init__.py:nonce | handlers/wallet.go | stub |
|
||||
|
||||
CREATE2 address must use **keccak256**, not sha256 (Python had the bug
|
||||
in Phase 3 — already fixed there, watch for it on port).
|
||||
|
||||
## /v1/bundler
|
||||
|
||||
| Method | Path | Python | Go | Status |
|
||||
|--------|---------------------------------------|---------------------------------|---------------------|--------|
|
||||
| POST | /v1/bundler/{chain} | api/bundler/__init__.py:rpc | handlers/wallet.go | stub |
|
||||
| POST | /v1/bundler/{chain}/{network} | api/bundler/__init__.py:rpc | handlers/wallet.go | stub |
|
||||
|
||||
`userOpHash` must use eth-abi-encoded keccak256.
|
||||
|
||||
## /v1/gas
|
||||
|
||||
| Method | Path | Python | Go | Status |
|
||||
|--------|-------------------------------|-------------------------------------|---------------------|--------|
|
||||
| GET | /v1/gas/{chain}/prices | api/gas/__init__.py:prices | handlers/wallet.go | stub |
|
||||
| POST | /v1/gas/sponsor | api/gas/__init__.py:sponsor | handlers/wallet.go | stub |
|
||||
| POST | /v1/gas/estimate | api/gas/__init__.py:estimate | handlers/wallet.go | stub |
|
||||
| GET | /v1/gas/policies | api/gas/__init__.py:list_policies | handlers/wallet.go | stub |
|
||||
| POST | /v1/gas/policies | api/gas/__init__.py:create_policy | handlers/wallet.go | stub |
|
||||
| GET | /v1/gas/policies/{id} | api/gas/__init__.py:get_policy | handlers/wallet.go | stub |
|
||||
| DELETE | /v1/gas/policies/{id} | api/gas/__init__.py:delete_policy | handlers/wallet.go | stub |
|
||||
|
||||
## /v1/webhooks
|
||||
|
||||
| Method | Path | Python | Go | Status |
|
||||
|--------|---------------------------------------|-------------------------------------|----------------------|--------|
|
||||
| POST | /v1/webhooks | api/webhooks/__init__.py:create | handlers/webhook.go | stub |
|
||||
| GET | /v1/webhooks | api/webhooks/__init__.py:list | handlers/webhook.go | stub |
|
||||
| GET | /v1/webhooks/{id} | api/webhooks/__init__.py:get | handlers/webhook.go | stub |
|
||||
| PATCH | /v1/webhooks/{id} | api/webhooks/__init__.py:update | handlers/webhook.go | stub |
|
||||
| DELETE | /v1/webhooks/{id} | api/webhooks/__init__.py:delete | handlers/webhook.go | stub |
|
||||
| GET | /v1/webhooks/{id}/deliveries | api/webhooks/__init__.py:deliveries | handlers/webhook.go | stub |
|
||||
| POST | /v1/webhooks/{id}/test | api/webhooks/__init__.py:test | handlers/webhook.go | stub |
|
||||
|
||||
Delivery worker (was Python arq) → `deps.MQ` subsystem.
|
||||
|
||||
## /v1/fleets
|
||||
|
||||
15 endpoints — see `api/fleets/__init__.py` and `handlers/infra.go`.
|
||||
All stubs.
|
||||
|
||||
## /v1/nodes
|
||||
|
||||
11 endpoints — see `api/nodes/__init__.py` and `handlers/infra.go`.
|
||||
All stubs. Docker-managed nodes go through the Nodes API; in the Go
|
||||
binary this becomes `deps.Compute` once that subsystem ships.
|
||||
|
||||
## /v1/infra
|
||||
|
||||
28 endpoints — see `api/infra/__init__.py` and `handlers/infra.go`.
|
||||
All stubs.
|
||||
|
||||
## /v1/team
|
||||
|
||||
4 endpoints — see `api/team/__init__.py` and `handlers/misc.go`. All
|
||||
stubs.
|
||||
|
||||
## /v1/networks
|
||||
|
||||
5 endpoints — see `api/networks/__init__.py` and `handlers/misc.go`.
|
||||
All stubs.
|
||||
|
||||
## /v1/o11y
|
||||
|
||||
8 endpoints — see `api/observability/__init__.py` and
|
||||
`handlers/misc.go`. All stubs.
|
||||
|
||||
## /v1/chat
|
||||
|
||||
2 endpoints — passthrough to the LLM gateway. Stubs.
|
||||
|
||||
## /v1/billing
|
||||
|
||||
24 endpoints — see `api/billing/__init__.py` and `handlers/misc.go`.
|
||||
Money never lives here; everything is delegated to `deps.Commerce`.
|
||||
Stubs.
|
||||
|
||||
## /v1/zap
|
||||
|
||||
7 endpoints (discovery only — wire-level ZAP RPC lives in
|
||||
`superbase`). Stubs. **No `/schema.capnp` is mounted** — brand policy.
|
||||
|
||||
## Internal (not HTTP-exposed)
|
||||
|
||||
| Surface | Python | Go |
|
||||
|---------------------------|-----------------------------------|-----------------------------------------------|
|
||||
| ZAP server | bootnode/zap/server.py | zap/gen/*.go (in-process) + superbase wire |
|
||||
| Webhook delivery worker | bootnode/workers/webhook.py | future `deps.MQ` worker |
|
||||
| Usage sync worker | bootnode/core/billing/sync.py | future cron in `service.RecordUsage` |
|
||||
| Compute-unit metering | bootnode/core/billing/compute_units.py | inline in handlers/rpc.go |
|
||||
|
||||
## Endpoint count
|
||||
|
||||
- Python total: 150 endpoints across 17 modules (FastAPI)
|
||||
- Go scaffold: 132 chi routes mounted (covers the public surface;
|
||||
internal `nodes/presets`, ZAP discovery, and a handful of admin
|
||||
endpoints are intentionally omitted until the subsystem ships)
|
||||
@@ -0,0 +1,5 @@
|
||||
module github.com/hanzoai/bootnode
|
||||
|
||||
go 1.26.2
|
||||
|
||||
require github.com/go-chi/chi/v5 v5.1.0
|
||||
@@ -0,0 +1,2 @@
|
||||
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
||||
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||
@@ -0,0 +1,341 @@
|
||||
# Hanzo Bootnode — ZAP Schema
|
||||
#
|
||||
# Public typed surface for the Bootnode subsystem (multi-chain Web3 API,
|
||||
# token/NFT/transfers/wallets/webhooks/AA/bundler/gas/fleets/infra).
|
||||
# Mounted into superbase per HIP-0106; addressable in-process via the
|
||||
# generated Go client and over-wire via ZAP RPC.
|
||||
#
|
||||
# Code generation:
|
||||
# zapc generate bootnode.zap --lang go --out ./zap/gen/
|
||||
#
|
||||
# Brand policy: ZAP only. Never "Cap'n Proto" / .capnp anywhere on the
|
||||
# public surface (see ~/.claude/CLAUDE.md + HIP-0106).
|
||||
|
||||
@version 1.0.0
|
||||
|
||||
# ── Enums ────────────────────────────────────────────────────────────────
|
||||
|
||||
enum Chain
|
||||
ethereum
|
||||
polygon
|
||||
arbitrum
|
||||
optimism
|
||||
base
|
||||
bsc
|
||||
avalanche
|
||||
gnosis
|
||||
celo
|
||||
linea
|
||||
scroll
|
||||
zksync
|
||||
blast
|
||||
mantle
|
||||
metis
|
||||
zora
|
||||
opbnb
|
||||
bitcoin
|
||||
solana
|
||||
aptos
|
||||
flow
|
||||
berachain
|
||||
sei
|
||||
sonic
|
||||
tron
|
||||
ton
|
||||
lux
|
||||
|
||||
enum Network
|
||||
mainnet
|
||||
sepolia
|
||||
holesky
|
||||
fuji
|
||||
devnet
|
||||
testnet
|
||||
localnet
|
||||
|
||||
enum WebhookEvent
|
||||
addressActivity
|
||||
minedTransaction
|
||||
nftActivity
|
||||
tokenTransfer
|
||||
internalTransfer
|
||||
graphql
|
||||
|
||||
enum NodeProvider
|
||||
docker
|
||||
kubernetes
|
||||
process
|
||||
helm
|
||||
|
||||
# ── RPC ──────────────────────────────────────────────────────────────────
|
||||
|
||||
struct RPCRequest
|
||||
chain Chain
|
||||
network Network
|
||||
method Text
|
||||
params Text # JSON-encoded params array
|
||||
id Int64
|
||||
|
||||
struct RPCResponse
|
||||
jsonrpc Text
|
||||
id Int64
|
||||
result Text # JSON-encoded result
|
||||
error Text # JSON-encoded error or empty
|
||||
blockNumber UInt64
|
||||
cached Bool
|
||||
|
||||
# ── Tokens ───────────────────────────────────────────────────────────────
|
||||
|
||||
struct TokenBalance
|
||||
contract Text
|
||||
symbol Text
|
||||
name Text
|
||||
decimals Int32
|
||||
balance Text # bigint as string
|
||||
balanceUsd Float64
|
||||
|
||||
struct TokenMetadata
|
||||
contract Text
|
||||
chain Chain
|
||||
symbol Text
|
||||
name Text
|
||||
decimals Int32
|
||||
logo Text
|
||||
totalSupply Text
|
||||
|
||||
# ── NFTs ─────────────────────────────────────────────────────────────────
|
||||
|
||||
struct NFTMetadata
|
||||
contract Text
|
||||
tokenId Text
|
||||
name Text
|
||||
description Text
|
||||
image Text
|
||||
attributes Text # JSON-encoded
|
||||
tokenUri Text
|
||||
chain Chain
|
||||
|
||||
struct NFTCollection
|
||||
contract Text
|
||||
chain Chain
|
||||
name Text
|
||||
symbol Text
|
||||
totalSupply Text
|
||||
ownerCount Int64
|
||||
|
||||
# ── Transfers ───────────────────────────────────────────────────────────
|
||||
|
||||
struct Transfer
|
||||
chain Chain
|
||||
txHash Text
|
||||
blockNumber UInt64
|
||||
fromAddr Text
|
||||
toAddr Text
|
||||
value Text
|
||||
asset Text
|
||||
category Text # external | internal | erc20 | erc721 | erc1155
|
||||
timestamp Int64
|
||||
|
||||
# ── Smart Wallets (ERC-4337) ────────────────────────────────────────────
|
||||
|
||||
struct SmartWallet
|
||||
address Text
|
||||
chain Chain
|
||||
owner Text
|
||||
factory Text
|
||||
salt Text
|
||||
nonce UInt64
|
||||
deployed Bool
|
||||
createdAt Int64
|
||||
|
||||
struct UserOp
|
||||
sender Text
|
||||
nonce Text
|
||||
initCode Text
|
||||
callData Text
|
||||
callGasLimit Text
|
||||
verificationGasLimit Text
|
||||
preVerificationGas Text
|
||||
maxFeePerGas Text
|
||||
maxPriorityFeePerGas Text
|
||||
paymasterAndData Text
|
||||
signature Text
|
||||
|
||||
struct UserOpReceipt
|
||||
userOpHash Text
|
||||
sender Text
|
||||
nonce Text
|
||||
success Bool
|
||||
txHash Text
|
||||
blockNumber UInt64
|
||||
gasUsed Text
|
||||
|
||||
# ── Gas Manager ─────────────────────────────────────────────────────────
|
||||
|
||||
struct GasPrices
|
||||
chain Chain
|
||||
slow Text
|
||||
standard Text
|
||||
fast Text
|
||||
baseFee Text
|
||||
maxPriorityFeePerGas Text
|
||||
updatedAt Int64
|
||||
|
||||
struct GasPolicy
|
||||
id Text
|
||||
orgId Text
|
||||
name Text
|
||||
chain Chain
|
||||
maxGwei Text
|
||||
dailyLimitUsd Float64
|
||||
enabled Bool
|
||||
|
||||
# ── Webhooks ────────────────────────────────────────────────────────────
|
||||
|
||||
struct Webhook
|
||||
id Text
|
||||
orgId Text
|
||||
url Text
|
||||
chain Chain
|
||||
event WebhookEvent
|
||||
filters Text # JSON-encoded
|
||||
secret Text
|
||||
active Bool
|
||||
createdAt Int64
|
||||
|
||||
struct WebhookDelivery
|
||||
id Text
|
||||
webhookId Text
|
||||
status Text
|
||||
responseCode Int32
|
||||
attempts Int32
|
||||
payload Text
|
||||
createdAt Int64
|
||||
|
||||
# ── API Keys / Projects ─────────────────────────────────────────────────
|
||||
|
||||
struct Project
|
||||
id Text
|
||||
orgId Text
|
||||
name Text
|
||||
createdAt Int64
|
||||
|
||||
struct APIKey
|
||||
id Text
|
||||
projectId Text
|
||||
orgId Text
|
||||
keyHash Text
|
||||
name Text
|
||||
scopes List(Text)
|
||||
rateLimit Int32
|
||||
createdAt Int64
|
||||
|
||||
struct CreateAPIKeyResult
|
||||
apiKey APIKey
|
||||
plaintext Text # returned ONCE on creation, never again
|
||||
|
||||
# ── Fleets / Nodes / Infra ──────────────────────────────────────────────
|
||||
|
||||
struct Fleet
|
||||
id Text
|
||||
orgId Text
|
||||
chain Chain
|
||||
network Network
|
||||
clusterId Text
|
||||
replicas Int32
|
||||
status Text
|
||||
createdAt Int64
|
||||
|
||||
struct Node
|
||||
id Text
|
||||
orgId Text
|
||||
name Text
|
||||
chain Chain
|
||||
network Network
|
||||
provider NodeProvider
|
||||
rpcPort Int32
|
||||
wsPort Int32
|
||||
status Text
|
||||
containerId Text
|
||||
createdAt Int64
|
||||
|
||||
struct Cluster
|
||||
id Text
|
||||
orgId Text
|
||||
name Text
|
||||
region Text
|
||||
nodeCount Int32
|
||||
status Text
|
||||
|
||||
# ── Usage / Billing surface (Bootnode reports CU to Commerce) ───────────
|
||||
|
||||
struct UsageRecord
|
||||
orgId Text
|
||||
projectId Text
|
||||
chain Chain
|
||||
method Text
|
||||
computeUnits Int32
|
||||
timestamp Int64
|
||||
|
||||
struct UsageSummary
|
||||
orgId Text
|
||||
total Int64
|
||||
periodStart Int64
|
||||
periodEnd Int64
|
||||
|
||||
# ── Service Interface ───────────────────────────────────────────────────
|
||||
|
||||
interface BootnodeService
|
||||
# RPC proxy
|
||||
rpc (req RPCRequest) -> (resp RPCResponse)
|
||||
|
||||
# Tokens
|
||||
tokenBalances (chain Chain, address Text) -> (balances List(TokenBalance))
|
||||
tokenMetadata (chain Chain, contract Text) -> (metadata TokenMetadata)
|
||||
|
||||
# NFTs
|
||||
nftMetadata (chain Chain, contract Text, tokenId Text) -> (metadata NFTMetadata)
|
||||
nftCollection (chain Chain, contract Text) -> (collection NFTCollection)
|
||||
nftsOwned (chain Chain, address Text) -> (items List(NFTMetadata))
|
||||
|
||||
# Transfers
|
||||
listTransfers (chain Chain, address Text) -> (transfers List(Transfer))
|
||||
|
||||
# Smart wallets
|
||||
createSmartWallet (chain Chain, owner Text) -> (wallet SmartWallet)
|
||||
getSmartWallet (address Text) -> (wallet SmartWallet)
|
||||
|
||||
# Bundler (ERC-4337)
|
||||
sendUserOp (chain Chain, op UserOp) -> (hash Text)
|
||||
getUserOpReceipt (chain Chain, hash Text) -> (receipt UserOpReceipt)
|
||||
|
||||
# Gas
|
||||
gasPrices (chain Chain) -> (prices GasPrices)
|
||||
sponsorUserOp (chain Chain, op UserOp, policyId Text) -> (paymasterAndData Text)
|
||||
|
||||
# Webhooks
|
||||
createWebhook (webhook Webhook) -> (created Webhook)
|
||||
listWebhooks (orgId Text) -> (webhooks List(Webhook))
|
||||
deleteWebhook (id Text) -> (ok Bool)
|
||||
|
||||
# API keys
|
||||
createAPIKey (orgId Text, projectId Text, name Text, scopes List(Text)) -> (result CreateAPIKeyResult)
|
||||
listAPIKeys (orgId Text) -> (keys List(APIKey))
|
||||
revokeAPIKey (orgId Text, id Text) -> (ok Bool)
|
||||
|
||||
# Fleets
|
||||
createFleet (fleet Fleet) -> (created Fleet)
|
||||
listFleets (orgId Text) -> (fleets List(Fleet))
|
||||
scaleFleet (id Text, replicas Int32) -> (fleet Fleet)
|
||||
deleteFleet (id Text) -> (ok Bool)
|
||||
|
||||
# Nodes (single-instance blockchain nodes via Docker/K8s)
|
||||
deployNode (node Node) -> (created Node)
|
||||
listNodes (orgId Text) -> (nodes List(Node))
|
||||
startNode (id Text) -> (ok Bool)
|
||||
stopNode (id Text) -> (ok Bool)
|
||||
deleteNode (id Text) -> (ok Bool)
|
||||
|
||||
# Usage (internal — Bootnode → Commerce)
|
||||
recordUsage (record UsageRecord) -> (ok Bool)
|
||||
getUsage (orgId Text, periodStart Int64, periodEnd Int64) -> (summary UsageSummary)
|
||||
@@ -0,0 +1,27 @@
|
||||
# Regenerate Go bindings from the public ZAP schema.
|
||||
#
|
||||
# Hand-rolled stubs live in ./gen/ so `go build` works without zapc
|
||||
# installed locally. When zapc is available the generated files take
|
||||
# their place; the package layout/import path remains stable.
|
||||
#
|
||||
# Brand policy: ZAP only — never reference Cap'n Proto on the public
|
||||
# surface (HIP-0106).
|
||||
|
||||
SCHEMA := ../schema/bootnode.zap
|
||||
OUT := ./gen
|
||||
|
||||
.PHONY: gen check clean
|
||||
|
||||
gen:
|
||||
@command -v zapc >/dev/null 2>&1 || { \
|
||||
echo "zapc not installed — keeping hand-rolled stubs in $(OUT)"; \
|
||||
exit 0; \
|
||||
}
|
||||
zapc generate $(SCHEMA) --lang go --out $(OUT)
|
||||
|
||||
check:
|
||||
@command -v zapc >/dev/null 2>&1 || { echo "zapc not installed"; exit 1; }
|
||||
zapc check $(SCHEMA)
|
||||
|
||||
clean:
|
||||
rm -f $(OUT)/*.gen.go
|
||||
@@ -0,0 +1,371 @@
|
||||
// Package bootnodezap is the generated typed surface for the Bootnode
|
||||
// subsystem. The contents here are normally regenerated by zapc from
|
||||
// ../../schema/bootnode.zap; the hand-rolled stubs below let `go build`
|
||||
// succeed when zapc is not installed and define the canonical type
|
||||
// shape that the generator emits.
|
||||
//
|
||||
// Brand policy: ZAP only. No Cap'n Proto references anywhere in the
|
||||
// public surface (HIP-0106).
|
||||
package bootnodezap
|
||||
|
||||
import "context"
|
||||
|
||||
// Chain is the cross-cutting blockchain identifier. Values are the
|
||||
// kebab-cased subset of HIP-0106 chains Bootnode speaks today.
|
||||
type Chain string
|
||||
|
||||
const (
|
||||
ChainEthereum Chain = "ethereum"
|
||||
ChainPolygon Chain = "polygon"
|
||||
ChainArbitrum Chain = "arbitrum"
|
||||
ChainOptimism Chain = "optimism"
|
||||
ChainBase Chain = "base"
|
||||
ChainBSC Chain = "bsc"
|
||||
ChainAvalanche Chain = "avalanche"
|
||||
ChainGnosis Chain = "gnosis"
|
||||
ChainCelo Chain = "celo"
|
||||
ChainLinea Chain = "linea"
|
||||
ChainScroll Chain = "scroll"
|
||||
ChainZkSync Chain = "zksync"
|
||||
ChainBlast Chain = "blast"
|
||||
ChainMantle Chain = "mantle"
|
||||
ChainMetis Chain = "metis"
|
||||
ChainZora Chain = "zora"
|
||||
ChainOpBNB Chain = "opbnb"
|
||||
ChainBitcoin Chain = "bitcoin"
|
||||
ChainSolana Chain = "solana"
|
||||
ChainAptos Chain = "aptos"
|
||||
ChainFlow Chain = "flow"
|
||||
ChainBerachain Chain = "berachain"
|
||||
ChainSei Chain = "sei"
|
||||
ChainSonic Chain = "sonic"
|
||||
ChainTron Chain = "tron"
|
||||
ChainTON Chain = "ton"
|
||||
ChainLux Chain = "lux"
|
||||
)
|
||||
|
||||
// Network identifies a chain's deployment environment.
|
||||
type Network string
|
||||
|
||||
const (
|
||||
NetworkMainnet Network = "mainnet"
|
||||
NetworkSepolia Network = "sepolia"
|
||||
NetworkHolesky Network = "holesky"
|
||||
NetworkFuji Network = "fuji"
|
||||
NetworkDevnet Network = "devnet"
|
||||
NetworkTestnet Network = "testnet"
|
||||
NetworkLocalnet Network = "localnet"
|
||||
)
|
||||
|
||||
// WebhookEvent enumerates the supported webhook subscription types.
|
||||
type WebhookEvent string
|
||||
|
||||
const (
|
||||
WebhookEventAddressActivity WebhookEvent = "addressActivity"
|
||||
WebhookEventMinedTransaction WebhookEvent = "minedTransaction"
|
||||
WebhookEventNFTActivity WebhookEvent = "nftActivity"
|
||||
WebhookEventTokenTransfer WebhookEvent = "tokenTransfer"
|
||||
WebhookEventInternalTransfer WebhookEvent = "internalTransfer"
|
||||
WebhookEventGraphQL WebhookEvent = "graphql"
|
||||
)
|
||||
|
||||
// NodeProvider identifies how a single-instance blockchain node is run.
|
||||
type NodeProvider string
|
||||
|
||||
const (
|
||||
NodeProviderDocker NodeProvider = "docker"
|
||||
NodeProviderKubernetes NodeProvider = "kubernetes"
|
||||
NodeProviderProcess NodeProvider = "process"
|
||||
NodeProviderHelm NodeProvider = "helm"
|
||||
)
|
||||
|
||||
// RPCRequest is a JSON-RPC envelope sent to the multi-chain proxy.
|
||||
type RPCRequest struct {
|
||||
Chain Chain `json:"chain"`
|
||||
Network Network `json:"network"`
|
||||
Method string `json:"method"`
|
||||
Params string `json:"params"`
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
// RPCResponse is the upstream's JSON-RPC reply, optionally annotated
|
||||
// with the block at which it was served and whether it was cached.
|
||||
type RPCResponse struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID int64 `json:"id"`
|
||||
Result string `json:"result"`
|
||||
Error string `json:"error"`
|
||||
BlockNumber uint64 `json:"blockNumber"`
|
||||
Cached bool `json:"cached"`
|
||||
}
|
||||
|
||||
// TokenBalance is one ERC-20 / native-coin entry for an address.
|
||||
type TokenBalance struct {
|
||||
Contract string `json:"contract"`
|
||||
Symbol string `json:"symbol"`
|
||||
Name string `json:"name"`
|
||||
Decimals int32 `json:"decimals"`
|
||||
Balance string `json:"balance"`
|
||||
BalanceUSD float64 `json:"balanceUsd"`
|
||||
}
|
||||
|
||||
// TokenMetadata describes a fungible token contract.
|
||||
type TokenMetadata struct {
|
||||
Contract string `json:"contract"`
|
||||
Chain Chain `json:"chain"`
|
||||
Symbol string `json:"symbol"`
|
||||
Name string `json:"name"`
|
||||
Decimals int32 `json:"decimals"`
|
||||
Logo string `json:"logo"`
|
||||
TotalSupply string `json:"totalSupply"`
|
||||
}
|
||||
|
||||
// NFTMetadata describes a single token within an NFT contract.
|
||||
type NFTMetadata struct {
|
||||
Contract string `json:"contract"`
|
||||
TokenID string `json:"tokenId"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Image string `json:"image"`
|
||||
Attributes string `json:"attributes"`
|
||||
TokenURI string `json:"tokenUri"`
|
||||
Chain Chain `json:"chain"`
|
||||
}
|
||||
|
||||
// NFTCollection summarizes an ERC-721 / ERC-1155 contract.
|
||||
type NFTCollection struct {
|
||||
Contract string `json:"contract"`
|
||||
Chain Chain `json:"chain"`
|
||||
Name string `json:"name"`
|
||||
Symbol string `json:"symbol"`
|
||||
TotalSupply string `json:"totalSupply"`
|
||||
OwnerCount int64 `json:"ownerCount"`
|
||||
}
|
||||
|
||||
// Transfer is one historical asset movement.
|
||||
type Transfer struct {
|
||||
Chain Chain `json:"chain"`
|
||||
TxHash string `json:"txHash"`
|
||||
BlockNumber uint64 `json:"blockNumber"`
|
||||
From string `json:"fromAddr"`
|
||||
To string `json:"toAddr"`
|
||||
Value string `json:"value"`
|
||||
Asset string `json:"asset"`
|
||||
Category string `json:"category"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
// SmartWallet is one ERC-4337 account record.
|
||||
type SmartWallet struct {
|
||||
Address string `json:"address"`
|
||||
Chain Chain `json:"chain"`
|
||||
Owner string `json:"owner"`
|
||||
Factory string `json:"factory"`
|
||||
Salt string `json:"salt"`
|
||||
Nonce uint64 `json:"nonce"`
|
||||
Deployed bool `json:"deployed"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
}
|
||||
|
||||
// UserOp is the canonical ERC-4337 v0.6 user-operation envelope.
|
||||
type UserOp struct {
|
||||
Sender string `json:"sender"`
|
||||
Nonce string `json:"nonce"`
|
||||
InitCode string `json:"initCode"`
|
||||
CallData string `json:"callData"`
|
||||
CallGasLimit string `json:"callGasLimit"`
|
||||
VerificationGasLimit string `json:"verificationGasLimit"`
|
||||
PreVerificationGas string `json:"preVerificationGas"`
|
||||
MaxFeePerGas string `json:"maxFeePerGas"`
|
||||
MaxPriorityFeePerGas string `json:"maxPriorityFeePerGas"`
|
||||
PaymasterAndData string `json:"paymasterAndData"`
|
||||
Signature string `json:"signature"`
|
||||
}
|
||||
|
||||
// UserOpReceipt is what the bundler returns for a successful userOp.
|
||||
type UserOpReceipt struct {
|
||||
UserOpHash string `json:"userOpHash"`
|
||||
Sender string `json:"sender"`
|
||||
Nonce string `json:"nonce"`
|
||||
Success bool `json:"success"`
|
||||
TxHash string `json:"txHash"`
|
||||
BlockNumber uint64 `json:"blockNumber"`
|
||||
GasUsed string `json:"gasUsed"`
|
||||
}
|
||||
|
||||
// GasPrices is the current gas-price surface for a chain.
|
||||
type GasPrices struct {
|
||||
Chain Chain `json:"chain"`
|
||||
Slow string `json:"slow"`
|
||||
Standard string `json:"standard"`
|
||||
Fast string `json:"fast"`
|
||||
BaseFee string `json:"baseFee"`
|
||||
MaxPriorityFeePerGas string `json:"maxPriorityFeePerGas"`
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// GasPolicy controls per-org paymaster sponsorship caps.
|
||||
type GasPolicy struct {
|
||||
ID string `json:"id"`
|
||||
OrgID string `json:"orgId"`
|
||||
Name string `json:"name"`
|
||||
Chain Chain `json:"chain"`
|
||||
MaxGwei string `json:"maxGwei"`
|
||||
DailyLimitUSD float64 `json:"dailyLimitUsd"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// Webhook is a customer subscription to chain events.
|
||||
type Webhook struct {
|
||||
ID string `json:"id"`
|
||||
OrgID string `json:"orgId"`
|
||||
URL string `json:"url"`
|
||||
Chain Chain `json:"chain"`
|
||||
Event WebhookEvent `json:"event"`
|
||||
Filters string `json:"filters"`
|
||||
Secret string `json:"secret"`
|
||||
Active bool `json:"active"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
}
|
||||
|
||||
// WebhookDelivery is one attempt to POST a webhook payload.
|
||||
type WebhookDelivery struct {
|
||||
ID string `json:"id"`
|
||||
WebhookID string `json:"webhookId"`
|
||||
Status string `json:"status"`
|
||||
ResponseCode int32 `json:"responseCode"`
|
||||
Attempts int32 `json:"attempts"`
|
||||
Payload string `json:"payload"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
}
|
||||
|
||||
// Project groups a customer's API keys + usage under one envelope.
|
||||
type Project struct {
|
||||
ID string `json:"id"`
|
||||
OrgID string `json:"orgId"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
}
|
||||
|
||||
// APIKey is the durable record. The plaintext is only returned once on
|
||||
// creation; everywhere else KeyHash is the bcrypt'd identifier.
|
||||
type APIKey struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"projectId"`
|
||||
OrgID string `json:"orgId"`
|
||||
KeyHash string `json:"keyHash"`
|
||||
Name string `json:"name"`
|
||||
Scopes []string `json:"scopes"`
|
||||
RateLimit int32 `json:"rateLimit"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
}
|
||||
|
||||
// CreateAPIKeyResult bundles the durable record with the
|
||||
// return-it-once plaintext.
|
||||
type CreateAPIKeyResult struct {
|
||||
APIKey APIKey `json:"apiKey"`
|
||||
Plaintext string `json:"plaintext"`
|
||||
}
|
||||
|
||||
// Fleet is a multi-replica blockchain node deployment.
|
||||
type Fleet struct {
|
||||
ID string `json:"id"`
|
||||
OrgID string `json:"orgId"`
|
||||
Chain Chain `json:"chain"`
|
||||
Network Network `json:"network"`
|
||||
ClusterID string `json:"clusterId"`
|
||||
Replicas int32 `json:"replicas"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
}
|
||||
|
||||
// Node is a single-instance blockchain node managed by Bootnode.
|
||||
type Node struct {
|
||||
ID string `json:"id"`
|
||||
OrgID string `json:"orgId"`
|
||||
Name string `json:"name"`
|
||||
Chain Chain `json:"chain"`
|
||||
Network Network `json:"network"`
|
||||
Provider NodeProvider `json:"provider"`
|
||||
RPCPort int32 `json:"rpcPort"`
|
||||
WSPort int32 `json:"wsPort"`
|
||||
Status string `json:"status"`
|
||||
ContainerID string `json:"containerId"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
}
|
||||
|
||||
// Cluster is a Kubernetes cluster Bootnode manages on the customer's
|
||||
// behalf (DOKS / EKS / GKE / AKS).
|
||||
type Cluster struct {
|
||||
ID string `json:"id"`
|
||||
OrgID string `json:"orgId"`
|
||||
Name string `json:"name"`
|
||||
Region string `json:"region"`
|
||||
NodeCount int32 `json:"nodeCount"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// UsageRecord is one billable API call.
|
||||
type UsageRecord struct {
|
||||
OrgID string `json:"orgId"`
|
||||
ProjectID string `json:"projectId"`
|
||||
Chain Chain `json:"chain"`
|
||||
Method string `json:"method"`
|
||||
ComputeUnits int32 `json:"computeUnits"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
// UsageSummary is a roll-up over a billing period.
|
||||
type UsageSummary struct {
|
||||
OrgID string `json:"orgId"`
|
||||
Total int64 `json:"total"`
|
||||
PeriodStart int64 `json:"periodStart"`
|
||||
PeriodEnd int64 `json:"periodEnd"`
|
||||
}
|
||||
|
||||
// Service is the typed surface every BootnodeService implementation
|
||||
// must satisfy. Both the in-process Go client and the ZAP RPC client
|
||||
// resolve through this interface.
|
||||
type Service interface {
|
||||
RPC(ctx context.Context, req RPCRequest) (RPCResponse, error)
|
||||
|
||||
TokenBalances(ctx context.Context, chain Chain, address string) ([]TokenBalance, error)
|
||||
TokenMetadata(ctx context.Context, chain Chain, contract string) (TokenMetadata, error)
|
||||
|
||||
NFTMetadata(ctx context.Context, chain Chain, contract, tokenID string) (NFTMetadata, error)
|
||||
NFTCollection(ctx context.Context, chain Chain, contract string) (NFTCollection, error)
|
||||
NFTsOwned(ctx context.Context, chain Chain, address string) ([]NFTMetadata, error)
|
||||
|
||||
ListTransfers(ctx context.Context, chain Chain, address string) ([]Transfer, error)
|
||||
|
||||
CreateSmartWallet(ctx context.Context, chain Chain, owner string) (SmartWallet, error)
|
||||
GetSmartWallet(ctx context.Context, address string) (SmartWallet, error)
|
||||
|
||||
SendUserOp(ctx context.Context, chain Chain, op UserOp) (string, error)
|
||||
GetUserOpReceipt(ctx context.Context, chain Chain, hash string) (UserOpReceipt, error)
|
||||
|
||||
GasPrices(ctx context.Context, chain Chain) (GasPrices, error)
|
||||
SponsorUserOp(ctx context.Context, chain Chain, op UserOp, policyID string) (string, error)
|
||||
|
||||
CreateWebhook(ctx context.Context, w Webhook) (Webhook, error)
|
||||
ListWebhooks(ctx context.Context, orgID string) ([]Webhook, error)
|
||||
DeleteWebhook(ctx context.Context, id string) (bool, error)
|
||||
|
||||
CreateAPIKey(ctx context.Context, orgID, projectID, name string, scopes []string) (CreateAPIKeyResult, error)
|
||||
ListAPIKeys(ctx context.Context, orgID string) ([]APIKey, error)
|
||||
RevokeAPIKey(ctx context.Context, orgID, id string) (bool, error)
|
||||
|
||||
CreateFleet(ctx context.Context, f Fleet) (Fleet, error)
|
||||
ListFleets(ctx context.Context, orgID string) ([]Fleet, error)
|
||||
ScaleFleet(ctx context.Context, id string, replicas int32) (Fleet, error)
|
||||
DeleteFleet(ctx context.Context, id string) (bool, error)
|
||||
|
||||
DeployNode(ctx context.Context, n Node) (Node, error)
|
||||
ListNodes(ctx context.Context, orgID string) ([]Node, error)
|
||||
StartNode(ctx context.Context, id string) (bool, error)
|
||||
StopNode(ctx context.Context, id string) (bool, error)
|
||||
DeleteNode(ctx context.Context, id string) (bool, error)
|
||||
|
||||
RecordUsage(ctx context.Context, r UsageRecord) (bool, error)
|
||||
GetUsage(ctx context.Context, orgID string, periodStart, periodEnd int64) (UsageSummary, error)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package bootnodezap
|
||||
|
||||
// Client is the typed handle other subsystems use to talk to
|
||||
// Bootnode. zapc emits two implementations: an in-process one that
|
||||
// forwards to a Server.Impl(), and an RPC one that frames ZAP over the
|
||||
// wire. The hand-rolled stub here is just the in-process variant — it
|
||||
// keeps the package buildable before zapc has been run.
|
||||
//
|
||||
// Per HIP-0106 the same Client interface serves both modes; superbase
|
||||
// hands the in-process Client to its sibling subsystems and never
|
||||
// looks at the wire side.
|
||||
type Client = Service
|
||||
|
||||
// NewInProcessClient returns a typed handle backed by a local Server.
|
||||
func NewInProcessClient(srv *Server) Client { return srv.Impl() }
|
||||
@@ -0,0 +1,21 @@
|
||||
package bootnodezap
|
||||
|
||||
// Server is the in-process / over-the-wire entrypoint for the
|
||||
// BootnodeService implementation. zapc emits a typed dispatcher here;
|
||||
// the hand-rolled stub keeps the surface compilable until the
|
||||
// generator is run.
|
||||
//
|
||||
// When wired by superbase, every subsystem registers its Server with
|
||||
// the local ZAP dispatcher and the typed Client returned by NewClient
|
||||
// resolves to direct Go calls — no marshalling, no network (HIP-0106).
|
||||
type Server struct {
|
||||
impl Service
|
||||
}
|
||||
|
||||
// NewServer wraps a concrete Service implementation for dispatch.
|
||||
func NewServer(impl Service) *Server { return &Server{impl: impl} }
|
||||
|
||||
// Impl returns the underlying Service. zapc-generated server code
|
||||
// uses this to dispatch on method-id; the hand-rolled stub exposes
|
||||
// it directly so handlers can call typed methods without a switch.
|
||||
func (s *Server) Impl() Service { return s.impl }
|
||||
Reference in New Issue
Block a user