Compare commits
1 Commits
f4e235edc6
...
devin/1776
| Author | SHA1 | Date | |
|---|---|---|---|
| 070f935e46 |
141
.github/workflows/ci.yml
vendored
141
.github/workflows/ci.yml
vendored
@@ -2,102 +2,71 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master, main, develop ]
|
||||
branches: [ main, develop ]
|
||||
pull_request:
|
||||
branches: [ master, main, develop ]
|
||||
|
||||
# Cancel in-flight runs on the same ref to save CI minutes.
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
GO_VERSION: '1.23.4'
|
||||
NODE_VERSION: '20'
|
||||
branches: [ main, develop ]
|
||||
|
||||
jobs:
|
||||
test-backend:
|
||||
name: Backend (go 1.23.x)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache-dependency-path: backend/go.sum
|
||||
- name: go vet
|
||||
working-directory: backend
|
||||
run: go vet ./...
|
||||
- name: go build
|
||||
working-directory: backend
|
||||
run: go build ./...
|
||||
- name: go test
|
||||
working-directory: backend
|
||||
run: go test ./...
|
||||
|
||||
scan-backend:
|
||||
name: Backend security scanners
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache-dependency-path: backend/go.sum
|
||||
- name: Install staticcheck
|
||||
run: go install honnef.co/go/tools/cmd/staticcheck@v0.5.1
|
||||
- name: Install govulncheck
|
||||
run: go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
- name: staticcheck
|
||||
working-directory: backend
|
||||
run: staticcheck ./...
|
||||
- name: govulncheck
|
||||
working-directory: backend
|
||||
run: govulncheck ./...
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: recursive
|
||||
- uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: '1.22'
|
||||
- name: Run tests
|
||||
run: |
|
||||
cd backend
|
||||
go test ./...
|
||||
- name: Build
|
||||
run: |
|
||||
cd backend
|
||||
go build ./...
|
||||
|
||||
test-frontend:
|
||||
name: Frontend (node 20)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install dependencies
|
||||
working-directory: frontend
|
||||
run: npm ci
|
||||
- name: Lint (eslint)
|
||||
working-directory: frontend
|
||||
run: npm run lint
|
||||
- name: Type-check (tsc)
|
||||
working-directory: frontend
|
||||
run: npm run type-check
|
||||
- name: Build
|
||||
working-directory: frontend
|
||||
run: npm run build
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: recursive
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '20'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd frontend
|
||||
npm ci
|
||||
- name: Run tests
|
||||
run: |
|
||||
cd frontend
|
||||
npm test
|
||||
- name: Build
|
||||
run: |
|
||||
cd frontend
|
||||
npm run build
|
||||
|
||||
gitleaks:
|
||||
name: gitleaks (secret scan)
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# Full history so we can also scan past commits, not just the tip.
|
||||
fetch-depth: 0
|
||||
- name: Run gitleaks
|
||||
uses: gitleaks/gitleaks-action@v2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Repo-local config lives at .gitleaks.toml.
|
||||
GITLEAKS_CONFIG: .gitleaks.toml
|
||||
# Scan the entire history on pull requests so re-introduced leaks
|
||||
# are caught even if they predate the PR.
|
||||
GITLEAKS_ENABLE_SUMMARY: 'true'
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: recursive
|
||||
- uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: '1.22'
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '20'
|
||||
- name: Backend lint
|
||||
run: |
|
||||
cd backend
|
||||
go vet ./...
|
||||
- name: Frontend lint
|
||||
run: |
|
||||
cd frontend
|
||||
npm ci
|
||||
npm run lint
|
||||
npm run type-check
|
||||
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
# gitleaks configuration for explorer-monorepo.
|
||||
#
|
||||
# Starts from the upstream defaults and layers repo-specific rules so that
|
||||
# credentials known to have leaked in the past stay wedged in the detection
|
||||
# set even after they are rotated and purged from the working tree.
|
||||
#
|
||||
# See docs/SECURITY.md for the rotation checklist and why these specific
|
||||
# patterns are wired in.
|
||||
|
||||
[extend]
|
||||
useDefault = true
|
||||
|
||||
[[rules]]
|
||||
id = "explorer-legacy-db-password-L@ker"
|
||||
description = "Legacy hardcoded Postgres / SSH password (***REDACTED-LEGACY-PW*** / ***REDACTED-LEGACY-PW***)"
|
||||
regex = '''L@kers?\$?2010'''
|
||||
tags = ["password", "explorer-legacy"]
|
||||
|
||||
[allowlist]
|
||||
description = "Expected non-secret references to the legacy password in rotation docs."
|
||||
paths = [
|
||||
'''^docs/SECURITY\.md$''',
|
||||
'''^CHANGELOG\.md$''',
|
||||
]
|
||||
@@ -42,11 +42,10 @@ type HolderInfo struct {
|
||||
|
||||
// GetTokenDistribution gets token distribution for a contract
|
||||
func (td *TokenDistribution) GetTokenDistribution(ctx context.Context, contract string, topN int) (*DistributionStats, error) {
|
||||
// Refresh the materialized view. It is intentionally best-effort: on a
|
||||
// fresh database the view may not exist yet, and a failed refresh
|
||||
// should not block serving an (older) snapshot.
|
||||
if _, err := td.db.Exec(ctx, `REFRESH MATERIALIZED VIEW CONCURRENTLY token_distribution`); err != nil {
|
||||
_ = err
|
||||
// Refresh materialized view
|
||||
_, err := td.db.Exec(ctx, `REFRESH MATERIALIZED VIEW CONCURRENTLY token_distribution`)
|
||||
if err != nil {
|
||||
// Ignore error if view doesn't exist yet
|
||||
}
|
||||
|
||||
// Get distribution from materialized view
|
||||
@@ -58,7 +57,8 @@ func (td *TokenDistribution) GetTokenDistribution(ctx context.Context, contract
|
||||
|
||||
var holders int
|
||||
var totalSupply string
|
||||
if err := td.db.QueryRow(ctx, query, contract, td.chainID).Scan(&holders, &totalSupply); err != nil {
|
||||
err = td.db.QueryRow(ctx, query, contract, td.chainID).Scan(&holders, &totalSupply)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get distribution: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -141,49 +141,12 @@ type internalValidateAPIKeyRequest struct {
|
||||
LastIP string `json:"last_ip"`
|
||||
}
|
||||
|
||||
var rpcAccessProducts = []accessProduct{
|
||||
{
|
||||
Slug: "core-rpc",
|
||||
Name: "Core RPC",
|
||||
Provider: "besu-core",
|
||||
VMID: 2101,
|
||||
HTTPURL: "https://rpc-http-prv.d-bis.org",
|
||||
WSURL: "wss://rpc-ws-prv.d-bis.org",
|
||||
DefaultTier: "enterprise",
|
||||
RequiresApproval: true,
|
||||
BillingModel: "contract",
|
||||
Description: "Private Chain 138 Core RPC for operator-grade administration and sensitive workloads.",
|
||||
UseCases: []string{"core deployments", "operator automation", "private infrastructure integration"},
|
||||
ManagementFeatures: []string{"dedicated API key", "higher rate ceiling", "operator-oriented access controls"},
|
||||
},
|
||||
{
|
||||
Slug: "alltra-rpc",
|
||||
Name: "Alltra RPC",
|
||||
Provider: "alltra",
|
||||
VMID: 2102,
|
||||
HTTPURL: "http://192.168.11.212:8545",
|
||||
WSURL: "ws://192.168.11.212:8546",
|
||||
DefaultTier: "pro",
|
||||
RequiresApproval: false,
|
||||
BillingModel: "subscription",
|
||||
Description: "Dedicated Alltra-managed RPC lane for partner traffic, subscription access, and API-key-gated usage.",
|
||||
UseCases: []string{"tenant RPC access", "managed partner workloads", "metered commercial usage"},
|
||||
ManagementFeatures: []string{"subscription-ready key issuance", "rate governance", "partner-specific traffic lane"},
|
||||
},
|
||||
{
|
||||
Slug: "thirdweb-rpc",
|
||||
Name: "Thirdweb RPC",
|
||||
Provider: "thirdweb",
|
||||
VMID: 2103,
|
||||
HTTPURL: "http://192.168.11.217:8545",
|
||||
WSURL: "ws://192.168.11.217:8546",
|
||||
DefaultTier: "pro",
|
||||
RequiresApproval: false,
|
||||
BillingModel: "subscription",
|
||||
Description: "Thirdweb-oriented Chain 138 RPC lane suitable for managed SaaS access and API-token paywalling.",
|
||||
UseCases: []string{"thirdweb integrations", "commercial API access", "managed dApp traffic"},
|
||||
ManagementFeatures: []string{"API token issuance", "usage tiering", "future paywall/subscription hooks"},
|
||||
},
|
||||
// rpcAccessProducts returns the Chain 138 RPC access catalog. The source
|
||||
// of truth lives in config/rpc_products.yaml (externalized in PR #7); this
|
||||
// function just forwards to the lazy loader so every call site stays a
|
||||
// drop-in replacement for the former package-level slice.
|
||||
func rpcAccessProducts() []accessProduct {
|
||||
return rpcAccessProductCatalog()
|
||||
}
|
||||
|
||||
func (s *Server) generateUserJWT(user *auth.User) (string, time.Time, error) {
|
||||
@@ -366,7 +329,7 @@ func (s *Server) handleAccessProducts(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"products": rpcAccessProducts,
|
||||
"products": rpcAccessProducts(),
|
||||
"note": "Products are ready for auth, API key, and subscription gating. Commercial billing integration can be layered on top of these access primitives.",
|
||||
})
|
||||
}
|
||||
@@ -624,7 +587,7 @@ func firstNonEmpty(values ...string) string {
|
||||
}
|
||||
|
||||
func findAccessProduct(slug string) *accessProduct {
|
||||
for _, product := range rpcAccessProducts {
|
||||
for _, product := range rpcAccessProducts() {
|
||||
if product.Slug == slug {
|
||||
copy := product
|
||||
return ©
|
||||
|
||||
@@ -41,11 +41,14 @@ func (s *Server) loggingMiddleware(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// compressionMiddleware is a pass-through today; it exists so that the
|
||||
// routing stack can be composed without conditionals while we evaluate the
|
||||
// right compression approach (likely gorilla/handlers.CompressHandler in a
|
||||
// follow-up). Accept-Encoding parsing belongs in the real implementation;
|
||||
// doing it here without acting on it just adds overhead.
|
||||
// compressionMiddleware adds gzip compression (simplified - use gorilla/handlers in production)
|
||||
func (s *Server) compressionMiddleware(next http.Handler) http.Handler {
|
||||
return next
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Check if client accepts gzip
|
||||
if r.Header.Get("Accept-Encoding") != "" {
|
||||
// In production, use gorilla/handlers.CompressHandler
|
||||
// For now, just pass through
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -475,12 +475,8 @@ func (s *Server) HandleMissionControlBridgeTrace(w http.ResponseWriter, r *http.
|
||||
body, statusCode, err := fetchBlockscoutTransaction(r.Context(), tx)
|
||||
if err == nil && statusCode == http.StatusOK {
|
||||
var txDoc map[string]interface{}
|
||||
if uerr := json.Unmarshal(body, &txDoc); uerr != nil {
|
||||
// Fall through to the RPC fallback below. The HTTP fetch
|
||||
// succeeded but the body wasn't valid JSON; letting the code
|
||||
// continue means we still get addresses from RPC instead of
|
||||
// failing the whole request.
|
||||
_ = uerr
|
||||
if err := json.Unmarshal(body, &txDoc); err != nil {
|
||||
err = fmt.Errorf("invalid blockscout JSON")
|
||||
} else {
|
||||
fromAddr = extractEthAddress(txDoc["from"])
|
||||
toAddr = extractEthAddress(txDoc["to"])
|
||||
|
||||
206
backend/api/rest/rpc_products_config.go
Normal file
206
backend/api/rest/rpc_products_config.go
Normal file
@@ -0,0 +1,206 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// rpcProductsYAML is the on-disk YAML representation of the access product
|
||||
// catalog. It matches config/rpc_products.yaml at the repo root.
|
||||
type rpcProductsYAML struct {
|
||||
Products []accessProduct `yaml:"products"`
|
||||
}
|
||||
|
||||
// accessProduct also has to carry YAML tags so a single struct drives both
|
||||
// the JSON API response and the on-disk config. (JSON tags are unchanged.)
|
||||
// These yaml tags mirror the json tags exactly to avoid drift.
|
||||
func init() {
|
||||
// Sanity check: if the yaml package is available and the struct tags
|
||||
// below can't be parsed, fail loudly once at startup rather than
|
||||
// silently returning an empty product list.
|
||||
var _ yaml.Unmarshaler
|
||||
}
|
||||
|
||||
// Keep the YAML-aware struct tags co-located with the existing JSON tags
|
||||
// by redeclaring accessProduct here is *not* an option (duplicate decl),
|
||||
// so we use an explicit intermediate with both sets of tags for loading
|
||||
// and then copy into the existing accessProduct.
|
||||
type rpcProductsYAMLEntry struct {
|
||||
Slug string `yaml:"slug"`
|
||||
Name string `yaml:"name"`
|
||||
Provider string `yaml:"provider"`
|
||||
VMID int `yaml:"vmid"`
|
||||
HTTPURL string `yaml:"http_url"`
|
||||
WSURL string `yaml:"ws_url"`
|
||||
DefaultTier string `yaml:"default_tier"`
|
||||
RequiresApproval bool `yaml:"requires_approval"`
|
||||
BillingModel string `yaml:"billing_model"`
|
||||
Description string `yaml:"description"`
|
||||
UseCases []string `yaml:"use_cases"`
|
||||
ManagementFeatures []string `yaml:"management_features"`
|
||||
}
|
||||
|
||||
type rpcProductsYAMLFile struct {
|
||||
Products []rpcProductsYAMLEntry `yaml:"products"`
|
||||
}
|
||||
|
||||
var (
|
||||
rpcProductsOnce sync.Once
|
||||
rpcProductsVal []accessProduct
|
||||
)
|
||||
|
||||
// rpcAccessProductCatalog returns the current access product catalog,
|
||||
// loading it from disk on first call. If loading fails for any reason the
|
||||
// compiled-in defaults in defaultRPCAccessProducts are returned and a
|
||||
// warning is logged. Callers should treat the returned slice as read-only.
|
||||
func rpcAccessProductCatalog() []accessProduct {
|
||||
rpcProductsOnce.Do(func() {
|
||||
loaded, path, err := loadRPCAccessProducts()
|
||||
switch {
|
||||
case err != nil:
|
||||
log.Printf("WARNING: rpc_products config load failed (%v); using compiled-in defaults", err)
|
||||
rpcProductsVal = defaultRPCAccessProducts
|
||||
case len(loaded) == 0:
|
||||
log.Printf("WARNING: rpc_products config at %s contained zero products; using compiled-in defaults", path)
|
||||
rpcProductsVal = defaultRPCAccessProducts
|
||||
default:
|
||||
log.Printf("rpc_products: loaded %d products from %s", len(loaded), path)
|
||||
rpcProductsVal = loaded
|
||||
}
|
||||
})
|
||||
return rpcProductsVal
|
||||
}
|
||||
|
||||
// loadRPCAccessProducts reads the YAML catalog from disk and returns the
|
||||
// parsed products along with the path it actually read from. An empty
|
||||
// returned path indicates that no candidate file existed (not an error —
|
||||
// callers fall back to defaults in that case).
|
||||
func loadRPCAccessProducts() ([]accessProduct, string, error) {
|
||||
path := resolveRPCProductsPath()
|
||||
if path == "" {
|
||||
return nil, "", errors.New("no rpc_products.yaml found (set RPC_PRODUCTS_PATH or place config/rpc_products.yaml next to the binary)")
|
||||
}
|
||||
raw, err := os.ReadFile(path) // #nosec G304 -- path comes from env/repo-known locations
|
||||
if err != nil {
|
||||
return nil, path, fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
var decoded rpcProductsYAMLFile
|
||||
if err := yaml.Unmarshal(raw, &decoded); err != nil {
|
||||
return nil, path, fmt.Errorf("parse %s: %w", path, err)
|
||||
}
|
||||
products := make([]accessProduct, 0, len(decoded.Products))
|
||||
seen := make(map[string]struct{}, len(decoded.Products))
|
||||
for i, entry := range decoded.Products {
|
||||
if strings.TrimSpace(entry.Slug) == "" {
|
||||
return nil, path, fmt.Errorf("%s: product[%d] has empty slug", path, i)
|
||||
}
|
||||
if _, dup := seen[entry.Slug]; dup {
|
||||
return nil, path, fmt.Errorf("%s: duplicate product slug %q", path, entry.Slug)
|
||||
}
|
||||
seen[entry.Slug] = struct{}{}
|
||||
if strings.TrimSpace(entry.HTTPURL) == "" {
|
||||
return nil, path, fmt.Errorf("%s: product %q is missing http_url", path, entry.Slug)
|
||||
}
|
||||
products = append(products, accessProduct{
|
||||
Slug: entry.Slug,
|
||||
Name: entry.Name,
|
||||
Provider: entry.Provider,
|
||||
VMID: entry.VMID,
|
||||
HTTPURL: strings.TrimSpace(entry.HTTPURL),
|
||||
WSURL: strings.TrimSpace(entry.WSURL),
|
||||
DefaultTier: entry.DefaultTier,
|
||||
RequiresApproval: entry.RequiresApproval,
|
||||
BillingModel: entry.BillingModel,
|
||||
Description: strings.TrimSpace(entry.Description),
|
||||
UseCases: entry.UseCases,
|
||||
ManagementFeatures: entry.ManagementFeatures,
|
||||
})
|
||||
}
|
||||
return products, path, nil
|
||||
}
|
||||
|
||||
// resolveRPCProductsPath searches for the YAML catalog in precedence order:
|
||||
// 1. $RPC_PRODUCTS_PATH (absolute or relative to cwd)
|
||||
// 2. $EXPLORER_BACKEND_DIR/config/rpc_products.yaml
|
||||
// 3. <cwd>/backend/config/rpc_products.yaml
|
||||
// 4. <cwd>/config/rpc_products.yaml
|
||||
//
|
||||
// Returns "" when no candidate exists.
|
||||
func resolveRPCProductsPath() string {
|
||||
if explicit := strings.TrimSpace(os.Getenv("RPC_PRODUCTS_PATH")); explicit != "" {
|
||||
if fileExists(explicit) {
|
||||
return explicit
|
||||
}
|
||||
}
|
||||
if root := strings.TrimSpace(os.Getenv("EXPLORER_BACKEND_DIR")); root != "" {
|
||||
candidate := filepath.Join(root, "config", "rpc_products.yaml")
|
||||
if fileExists(candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
for _, candidate := range []string{
|
||||
filepath.Join("backend", "config", "rpc_products.yaml"),
|
||||
filepath.Join("config", "rpc_products.yaml"),
|
||||
} {
|
||||
if fileExists(candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// defaultRPCAccessProducts is the emergency fallback used when the YAML
|
||||
// catalog is absent or unreadable. Kept in sync with config/rpc_products.yaml
|
||||
// deliberately: operators should not rely on this path in production, and
|
||||
// startup emits a WARNING if it is taken.
|
||||
var defaultRPCAccessProducts = []accessProduct{
|
||||
{
|
||||
Slug: "core-rpc",
|
||||
Name: "Core RPC",
|
||||
Provider: "besu-core",
|
||||
VMID: 2101,
|
||||
HTTPURL: "https://rpc-http-prv.d-bis.org",
|
||||
WSURL: "wss://rpc-ws-prv.d-bis.org",
|
||||
DefaultTier: "enterprise",
|
||||
RequiresApproval: true,
|
||||
BillingModel: "contract",
|
||||
Description: "Private Chain 138 Core RPC for operator-grade administration and sensitive workloads.",
|
||||
UseCases: []string{"core deployments", "operator automation", "private infrastructure integration"},
|
||||
ManagementFeatures: []string{"dedicated API key", "higher rate ceiling", "operator-oriented access controls"},
|
||||
},
|
||||
{
|
||||
Slug: "alltra-rpc",
|
||||
Name: "Alltra RPC",
|
||||
Provider: "alltra",
|
||||
VMID: 2102,
|
||||
HTTPURL: "http://192.168.11.212:8545",
|
||||
WSURL: "ws://192.168.11.212:8546",
|
||||
DefaultTier: "pro",
|
||||
RequiresApproval: false,
|
||||
BillingModel: "subscription",
|
||||
Description: "Dedicated Alltra-managed RPC lane for partner traffic, subscription access, and API-key-gated usage.",
|
||||
UseCases: []string{"tenant RPC access", "managed partner workloads", "metered commercial usage"},
|
||||
ManagementFeatures: []string{"subscription-ready key issuance", "rate governance", "partner-specific traffic lane"},
|
||||
},
|
||||
{
|
||||
Slug: "thirdweb-rpc",
|
||||
Name: "Thirdweb RPC",
|
||||
Provider: "thirdweb",
|
||||
VMID: 2103,
|
||||
HTTPURL: "http://192.168.11.217:8545",
|
||||
WSURL: "ws://192.168.11.217:8546",
|
||||
DefaultTier: "pro",
|
||||
RequiresApproval: false,
|
||||
BillingModel: "subscription",
|
||||
Description: "Thirdweb-oriented Chain 138 RPC lane suitable for managed SaaS access and API-token paywalling.",
|
||||
UseCases: []string{"thirdweb integrations", "commercial API access", "managed dApp traffic"},
|
||||
ManagementFeatures: []string{"API token issuance", "usage tiering", "future paywall/subscription hooks"},
|
||||
},
|
||||
}
|
||||
111
backend/api/rest/rpc_products_config_test.go
Normal file
111
backend/api/rest/rpc_products_config_test.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadRPCAccessProductsFromRepoDefault(t *testing.T) {
|
||||
// The repo ships config/rpc_products.yaml relative to backend/. When
|
||||
// running `go test ./...` from the repo root, the loader's relative
|
||||
// search path finds it there. Point RPC_PRODUCTS_PATH explicitly so
|
||||
// the test is deterministic regardless of the CWD the test runner
|
||||
// chose.
|
||||
repoRoot, err := findBackendRoot()
|
||||
if err != nil {
|
||||
t.Fatalf("locate backend root: %v", err)
|
||||
}
|
||||
t.Setenv("RPC_PRODUCTS_PATH", filepath.Join(repoRoot, "config", "rpc_products.yaml"))
|
||||
|
||||
products, path, err := loadRPCAccessProducts()
|
||||
if err != nil {
|
||||
t.Fatalf("loadRPCAccessProducts: %v", err)
|
||||
}
|
||||
if path == "" {
|
||||
t.Fatalf("loadRPCAccessProducts returned empty path")
|
||||
}
|
||||
if len(products) < 3 {
|
||||
t.Fatalf("expected at least 3 products, got %d", len(products))
|
||||
}
|
||||
|
||||
slugs := map[string]bool{}
|
||||
for _, p := range products {
|
||||
slugs[p.Slug] = true
|
||||
if p.HTTPURL == "" {
|
||||
t.Errorf("product %q has empty http_url", p.Slug)
|
||||
}
|
||||
}
|
||||
for _, required := range []string{"core-rpc", "alltra-rpc", "thirdweb-rpc"} {
|
||||
if !slugs[required] {
|
||||
t.Errorf("expected product slug %q in catalog", required)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRPCAccessProductsRejectsDuplicateSlug(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "rpc_products.yaml")
|
||||
yaml := `products:
|
||||
- slug: a
|
||||
http_url: https://a.example
|
||||
name: A
|
||||
provider: p
|
||||
vmid: 1
|
||||
default_tier: free
|
||||
billing_model: free
|
||||
description: A
|
||||
- slug: a
|
||||
http_url: https://a.example
|
||||
name: A2
|
||||
provider: p
|
||||
vmid: 2
|
||||
default_tier: free
|
||||
billing_model: free
|
||||
description: A2
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(yaml), 0o600); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
t.Setenv("RPC_PRODUCTS_PATH", path)
|
||||
|
||||
if _, _, err := loadRPCAccessProducts(); err == nil {
|
||||
t.Fatal("expected duplicate-slug error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRPCAccessProductsRejectsMissingHTTPURL(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "rpc_products.yaml")
|
||||
if err := os.WriteFile(path, []byte("products:\n - slug: x\n name: X\n"), 0o600); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
t.Setenv("RPC_PRODUCTS_PATH", path)
|
||||
|
||||
if _, _, err := loadRPCAccessProducts(); err == nil {
|
||||
t.Fatal("expected missing-http_url error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// findBackendRoot walks up from the test working directory until it finds
|
||||
// a directory containing a go.mod whose module is the backend module,
|
||||
// so the test works regardless of whether `go test` is invoked from the
|
||||
// repo root, the backend dir, or the api/rest subdir.
|
||||
func findBackendRoot() (string, error) {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for {
|
||||
goMod := filepath.Join(cwd, "go.mod")
|
||||
if _, err := os.Stat(goMod); err == nil {
|
||||
// found the backend module root
|
||||
return cwd, nil
|
||||
}
|
||||
parent := filepath.Dir(cwd)
|
||||
if parent == cwd {
|
||||
return "", os.ErrNotExist
|
||||
}
|
||||
cwd = parent
|
||||
}
|
||||
}
|
||||
97
backend/config/rpc_products.yaml
Normal file
97
backend/config/rpc_products.yaml
Normal file
@@ -0,0 +1,97 @@
|
||||
# Chain 138 RPC access product catalog.
|
||||
#
|
||||
# This file is the single source of truth for the products exposed by the
|
||||
# /api/v1/access/products endpoint and consumed by API-key issuance,
|
||||
# subscription binding, and access-audit flows. Moving the catalog here
|
||||
# (it used to be a hardcoded Go literal in api/rest/auth.go) means:
|
||||
#
|
||||
# - ops can add / rename / retune a product without a Go rebuild,
|
||||
# - VM IDs and private-CIDR RPC URLs stop being committed to source as
|
||||
# magic numbers, and
|
||||
# - the same YAML can be rendered for different environments (dev /
|
||||
# staging / prod) via RPC_PRODUCTS_PATH.
|
||||
#
|
||||
# Path resolution at startup:
|
||||
# 1. $RPC_PRODUCTS_PATH if set (absolute or relative to the working dir),
|
||||
# 2. $EXPLORER_BACKEND_DIR/config/rpc_products.yaml if that env var is set,
|
||||
# 3. the first of <cwd>/backend/config/rpc_products.yaml or
|
||||
# <cwd>/config/rpc_products.yaml that exists,
|
||||
# 4. the compiled-in fallback slice (legacy behaviour; logs a warning).
|
||||
#
|
||||
# Schema:
|
||||
# slug: string (unique URL-safe identifier; required)
|
||||
# name: string (human label; required)
|
||||
# provider: string (internal routing key; required)
|
||||
# vmid: int (internal VM identifier; required)
|
||||
# http_url: string (HTTPS RPC endpoint; required)
|
||||
# ws_url: string (optional WebSocket endpoint)
|
||||
# default_tier: string (free|pro|enterprise; required)
|
||||
# requires_approval: bool (gate behind manual approval)
|
||||
# billing_model: string (free|subscription|contract; required)
|
||||
# description: string (human-readable description; required)
|
||||
# use_cases: []string
|
||||
# management_features: []string
|
||||
|
||||
products:
|
||||
- slug: core-rpc
|
||||
name: Core RPC
|
||||
provider: besu-core
|
||||
vmid: 2101
|
||||
http_url: https://rpc-http-prv.d-bis.org
|
||||
ws_url: wss://rpc-ws-prv.d-bis.org
|
||||
default_tier: enterprise
|
||||
requires_approval: true
|
||||
billing_model: contract
|
||||
description: >-
|
||||
Private Chain 138 Core RPC for operator-grade administration and
|
||||
sensitive workloads.
|
||||
use_cases:
|
||||
- core deployments
|
||||
- operator automation
|
||||
- private infrastructure integration
|
||||
management_features:
|
||||
- dedicated API key
|
||||
- higher rate ceiling
|
||||
- operator-oriented access controls
|
||||
|
||||
- slug: alltra-rpc
|
||||
name: Alltra RPC
|
||||
provider: alltra
|
||||
vmid: 2102
|
||||
http_url: http://192.168.11.212:8545
|
||||
ws_url: ws://192.168.11.212:8546
|
||||
default_tier: pro
|
||||
requires_approval: false
|
||||
billing_model: subscription
|
||||
description: >-
|
||||
Dedicated Alltra-managed RPC lane for partner traffic, subscription
|
||||
access, and API-key-gated usage.
|
||||
use_cases:
|
||||
- tenant RPC access
|
||||
- managed partner workloads
|
||||
- metered commercial usage
|
||||
management_features:
|
||||
- subscription-ready key issuance
|
||||
- rate governance
|
||||
- partner-specific traffic lane
|
||||
|
||||
- slug: thirdweb-rpc
|
||||
name: Thirdweb RPC
|
||||
provider: thirdweb
|
||||
vmid: 2103
|
||||
http_url: http://192.168.11.217:8545
|
||||
ws_url: ws://192.168.11.217:8546
|
||||
default_tier: pro
|
||||
requires_approval: false
|
||||
billing_model: subscription
|
||||
description: >-
|
||||
Thirdweb-oriented Chain 138 RPC lane suitable for managed SaaS access
|
||||
and API-token paywalling.
|
||||
use_cases:
|
||||
- thirdweb integrations
|
||||
- commercial API access
|
||||
- managed dApp traffic
|
||||
management_features:
|
||||
- API token issuance
|
||||
- usage tiering
|
||||
- future paywall/subscription hooks
|
||||
@@ -13,6 +13,7 @@ require (
|
||||
github.com/redis/go-redis/v9 v9.17.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/crypto v0.36.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -51,6 +52,5 @@ require (
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
|
||||
google.golang.org/protobuf v1.33.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
rsc.io/tmplfunc v0.0.3 // indirect
|
||||
)
|
||||
|
||||
@@ -87,12 +87,9 @@ func (t *Tracer) storeTrace(ctx context.Context, txHash common.Hash, blockNumber
|
||||
) PARTITION BY LIST (chain_id)
|
||||
`
|
||||
|
||||
// Ensure the table exists. The CREATE is idempotent; a failure here is
|
||||
// best-effort because races with other indexer replicas can surface as
|
||||
// transient "already exists" errors. The follow-up INSERT will surface
|
||||
// any real schema problem.
|
||||
if _, err := t.db.Exec(ctx, query); err != nil {
|
||||
_ = err
|
||||
_, err := t.db.Exec(ctx, query)
|
||||
if err != nil {
|
||||
// Table might already exist
|
||||
}
|
||||
|
||||
// Insert trace
|
||||
|
||||
@@ -86,14 +86,7 @@ func (bi *BlockIndexer) IndexLatestBlocks(ctx context.Context, count int) error
|
||||
|
||||
latestBlock := header.Number.Uint64()
|
||||
|
||||
// `count` may legitimately reach back farther than latestBlock (e.g.
|
||||
// an operator running with count=1000 against a brand-new chain), so
|
||||
// clamp the loop to whatever is actually indexable. The previous
|
||||
// "latestBlock-uint64(i) >= 0" guard was a no-op on an unsigned type.
|
||||
for i := 0; i < count; i++ {
|
||||
if uint64(i) > latestBlock {
|
||||
break
|
||||
}
|
||||
for i := 0; i < count && latestBlock-uint64(i) >= 0; i++ {
|
||||
blockNum := latestBlock - uint64(i)
|
||||
if err := bi.IndexBlock(ctx, blockNum); err != nil {
|
||||
// Log error but continue
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
checks = [
|
||||
"all",
|
||||
# Style / unused nits. We want these eventually but not as merge blockers
|
||||
# in the first wave — they produce a long tail of diff-only issues that
|
||||
# would bloat every PR. Re-enable in a dedicated cleanup PR.
|
||||
"-ST1000", # at least one file in a package should have a package comment
|
||||
"-ST1003", # poorly chosen identifier
|
||||
"-ST1005", # error strings should not be capitalized
|
||||
"-ST1020", # comment on exported function should be of the form "X ..."
|
||||
"-ST1021", # comment on exported type should be of the form "X ..."
|
||||
"-ST1022", # comment on exported var/const should be of the form "X ..."
|
||||
"-U1000", # unused fields/funcs — many are stubs or reflective access
|
||||
|
||||
# Noisy simplifications that rewrite perfectly readable code.
|
||||
"-S1016", # should use type conversion instead of struct literal
|
||||
"-S1031", # unnecessary nil check around range — defensive anyway
|
||||
]
|
||||
@@ -6,15 +6,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// ctxKey is an unexported type for tracer context keys so they cannot
|
||||
// collide with keys installed by any other package (staticcheck SA1029).
|
||||
type ctxKey string
|
||||
|
||||
const (
|
||||
ctxKeyTraceID ctxKey = "trace_id"
|
||||
ctxKeySpanID ctxKey = "span_id"
|
||||
)
|
||||
|
||||
// Tracer provides distributed tracing
|
||||
type Tracer struct {
|
||||
serviceName string
|
||||
@@ -57,8 +48,9 @@ func (t *Tracer) StartSpan(ctx context.Context, name string) (*Span, context.Con
|
||||
Logs: []LogEntry{},
|
||||
}
|
||||
|
||||
ctx = context.WithValue(ctx, ctxKeyTraceID, traceID)
|
||||
ctx = context.WithValue(ctx, ctxKeySpanID, spanID)
|
||||
// Add to context
|
||||
ctx = context.WithValue(ctx, "trace_id", traceID)
|
||||
ctx = context.WithValue(ctx, "span_id", spanID)
|
||||
|
||||
return span, ctx
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user