diff --git a/.github/env/90-project.env b/.github/env/90-project.env index a617050..d28c829 100644 --- a/.github/env/90-project.env +++ b/.github/env/90-project.env @@ -43,10 +43,10 @@ GO_COVERAGE_EXCLUDE_PATHS=.github/,.mage-cache/,.vscode/,bin/,example/,examples/ # ================================================================================================ # Nancy CVE Exclusions -NANCY_EXCLUDES=CVE-2024-38513,CVE-2023-45142,CVE-2025-64702,CVE-2021-43668,CVE-2023-26248,CVE-2026-24051,CVE-2026-26014 +NANCY_EXCLUDES=CVE-2024-38513,CVE-2023-45142,CVE-2025-64702,CVE-2021-43668,CVE-2023-26248,CVE-2026-24051,CVE-2026-26014,CVE-2026-26958 # Govulncheck/Magex CVE Exclusions -MAGE_X_CVE_EXCLUDES=CVE-2024-38513,CVE-2023-45142,CVE-2025-64702,CVE-2021-43668,CVE-2023-26248,CVE-2026-24051,CVE-2026-26014 +MAGE_X_CVE_EXCLUDES=CVE-2024-38513,CVE-2023-45142,CVE-2025-64702,CVE-2021-43668,CVE-2023-26248,CVE-2026-24051,CVE-2026-26014,CVE-2026-26958 # CVE-2026-26014 for pion/dtls (EXCLUDED: Invalid/non-existent CVE) # @@ -58,6 +58,13 @@ MAGE_X_CVE_EXCLUDES=CVE-2024-38513,CVE-2023-45142,CVE-2025-64702,CVE-2021-43668, # Rationale: No actual vulnerability exists. Nancy likely reporting stale/incorrect data. # Resolution: Excluded as false positive. Already using latest pion/dtls versions. +# CVE-2026-26958 for filippo.io/edwards25519@v1.1.0 (CWE-665 Improper Initialization) +# Transitive dependency via github.com/bsv-blockchain/teranode@v0.13.2. +# Advisory notes that uses "only through github.com/go-sql-driver/mysql are not affected", +# which is our path (teranode uses it for MySQL auth). Cannot upgrade via go mod tidy since +# no package in this module directly imports edwards25519; pin would be removed. Will resolve +# when teranode upgrades to filippo.io/edwards25519 v1.2.0. + # CVE-2026-24051 for go.opentelemetry.io/otel/sdk@v1.39.0 (macOS PATH hijacking - low risk) # CVE-2025-64702 for quic-go@v0.55.0 # GO-2024-3218: Content Censorship in IPFS via Kademlia DHT abuse in github.com/libp2p/go-libp2p-kad-dht diff --git a/.gitignore b/.gitignore index 12fec2d..916609d 100644 --- a/.gitignore +++ b/.gitignore @@ -45,5 +45,5 @@ peer_cache.json *.log -arcade +/arcade config.yaml diff --git a/DEPLOY.md b/DEPLOY.md index ff0d540..85d5646 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -17,7 +17,10 @@ Arcade is configured via environment variables or a `config.yaml` file. Environm | `ARCADE_STORAGE_PATH` | Root directory for persistent data | `/data` | | `ARCADE_DATABASE_SQLITE_PATH` | Path to SQLite database file | `/data/arcade.db` | | `ARCADE_CHAINTRACKS_STORAGE_PATH` | Path for chain header storage | `/data/chaintracks` | +| `ARCADE_CHAINTRACKS_BOOTSTRAP_URL` | URL to headers.bin for initial header sync | _(none)_ | | `ARCADE_TERANODE_BROADCAST_URLS` | Comma-separated Teranode propagation URLs | _(none)_ | +| `ARCADE_TERANODE_DATAHUB_URLS` | Comma-separated Teranode DataHub URLs (fallback) | _(none)_ | +| `ARCADE_TERANODE_AUTH_TOKEN` | **Bearer token for Teranode authentication (required)** | _(none)_ | | `ARCADE_SERVER_ADDRESS` | Listen address | `:3011` | | `ARCADE_LOG_LEVEL` | Log level: `debug`, `info`, `warn`, `error` | `info` | | `ARCADE_AUTH_ENABLED` | Enable authentication | `false` | @@ -33,34 +36,51 @@ The container exposes `GET /health` on port 3011. Use this for readiness/livenes ## Docker Compose -1. **Create a config file:** +The `docker-compose.yaml` includes production mainnet configuration by default. + +### Authentication Required + +⚠️ **You must provide a Teranode authentication token** to submit transactions: ```bash -cp config.example.yaml config.yaml +export ARCADE_TERANODE_AUTH_TOKEN="your-token-here" ``` -Edit `config.yaml` and set your `teranode.broadcast_urls`. - -2. **Start Arcade:** +### Quick Start ```bash +# Start Arcade (uses mainnet by default) docker compose up -d -``` -3. **Verify it's running:** +# View logs +docker compose logs -f arcade -```bash +# Check health curl http://localhost:3011/health -docker compose logs -f arcade + +# Stop +docker compose down ``` -4. **Stop:** +Data is persisted in the `arcade-data` Docker volume and survives restarts. + +### Using Different Networks +For testnet: ```bash -docker compose down +ARCADE_NETWORK=test \ +ARCADE_TERANODE_BROADCAST_URLS="https://teranode-eks-testnet-eu-1-propagation.bsvb.tech,https://teranode-eks-testnet-us-1-propagation.bsvb.tech" \ +ARCADE_TERANODE_DATAHUB_URLS="https://teranode-eks-testnet-eu-1.bsvb.tech/api/v1" \ +docker compose up -d ``` -Data is persisted in the `arcade-data` Docker volume and survives restarts. +For teratestnet: +```bash +ARCADE_NETWORK=teratestnet \ +ARCADE_TERANODE_BROADCAST_URLS="https://teranode-eks-ttn-eu-1-propagation.bsvb.tech,https://teranode-eks-ttn-us-1-propagation.bsvb.tech" \ +ARCADE_TERANODE_DATAHUB_URLS="https://teranode-eks-ttn-eu-1.bsvb.tech/api/v1" \ +docker compose up -d +``` ### Environment-only configuration (no config file) diff --git a/Dockerfile b/Dockerfile index a265598..80753a5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # syntax=docker/dockerfile:1 # Build stage -FROM golang:1.25.4-alpine@sha256:96f36e77302b6982abdd9849dff329feef03b0f2520c24dc2352fc4b33ed776d AS builder +FROM golang:1.25.4-alpine AS builder # Install build dependencies # build-base: includes gcc, g++, make and other build tools for CGO @@ -29,18 +29,14 @@ ARG BUILD_DATE=unknown # -ldflags explanation: # -s: omit symbol table # -w: omit DWARF debug info -# -X: set version variables RUN CGO_ENABLED=1 GOOS=linux go build \ - -ldflags="-s -w \ - -X main.version=${VERSION} \ - -X main.commit=${COMMIT} \ - -X main.buildDate=${BUILD_DATE}" \ + -ldflags="-s -w" \ -trimpath \ -o arcade \ ./cmd/arcade # Runtime stage - using Alpine for small size and C library compatibility (SQLite) -FROM alpine:3.21@sha256:22e0ec13c0db6b3e1ba3280e831fc50ba7bffe58e81f31670a64b1afede247bc +FROM alpine:3.21 # Install runtime dependencies # ca-certificates: for HTTPS connections to Teranode diff --git a/cmd/arcade/main.go b/cmd/arcade/main.go index 7a94b2c..202db9f 100644 --- a/cmd/arcade/main.go +++ b/cmd/arcade/main.go @@ -28,6 +28,7 @@ import ( "os" "os/signal" "syscall" + "time" chaintracksRoutes "github.com/bsv-blockchain/go-chaintracks/routes/fiber" "github.com/gofiber/fiber/v2" @@ -115,7 +116,7 @@ func run(ctx context.Context, cfg *config.Config, log *slog.Logger) error { authToken = cfg.Auth.Token log.Info("API authentication enabled") } - app := setupServer(arcadeRoutes, chaintracksRts, dashboard, authToken) + app := setupServer(arcadeRoutes, chaintracksRts, dashboard, authToken, cfg.Chaintracks.StoragePath) errCh := make(chan error, 1) go func() { @@ -156,7 +157,7 @@ func waitForShutdown(ctx context.Context, cfg *config.Config, log *slog.Logger, return nil } -func setupServer(arcadeRoutes *fiberRoutes.Routes, chaintracksRts *chaintracksRoutes.Routes, dashboard *Dashboard, authToken string) *fiber.App { +func setupServer(arcadeRoutes *fiberRoutes.Routes, chaintracksRts *chaintracksRoutes.Routes, dashboard *Dashboard, authToken string, chaintracksStoragePath string) *fiber.App { app := fiber.New(fiber.Config{ DisableStartupMessage: true, }) @@ -183,6 +184,15 @@ func setupServer(arcadeRoutes *fiberRoutes.Routes, chaintracksRts *chaintracksRo chaintracksGroup := app.Group("/chaintracks") chaintracksRts.Register(chaintracksGroup.Group("/v2")) chaintracksRts.RegisterLegacy(chaintracksGroup.Group("/v1")) + + // CDN static file serving for bulk header downloads + // Serves files like mainNetBlockHeaders.json and mainNet_X.headers + chaintracksGroup.Static("/", chaintracksStoragePath, fiber.Static{ + Compress: true, + ByteRange: true, // Support Range requests for partial downloads + Browse: false, + CacheDuration: 1 * time.Hour, + }) } // Health check (standalone arcade server only) @@ -194,7 +204,20 @@ func setupServer(arcadeRoutes *fiberRoutes.Routes, chaintracksRts *chaintracksRo // API docs (Scalar UI) app.Get("/docs/openapi.json", func(c *fiber.Ctx) error { - return c.Type("json").SendString(docs.SwaggerInfo.ReadDoc()) + arcadeSpec := docs.SwaggerInfo.ReadDoc() + + // Merge with chaintracks spec if chaintracks routes are enabled + if chaintracksRts != nil { + mergedSpec, err := mergeOpenAPISpecs(arcadeSpec, "/chaintracks") + if err != nil { + // Log error but fallback to arcade-only spec + slog.Error("Failed to merge OpenAPI specs", slog.String("error", err.Error())) + return c.Type("json").SendString(arcadeSpec) + } + return c.Type("json").SendString(mergedSpec) + } + + return c.Type("json").SendString(arcadeSpec) }) app.Get("/docs", func(c *fiber.Ctx) error { return c.Type("html").SendString(scalarHTML) diff --git a/cmd/arcade/openapi.go b/cmd/arcade/openapi.go new file mode 100644 index 0000000..9e8338a --- /dev/null +++ b/cmd/arcade/openapi.go @@ -0,0 +1,265 @@ +package main + +import ( + _ "embed" + "encoding/json" + "fmt" + "strings" + + "gopkg.in/yaml.v3" +) + +//go:embed static/chaintracks-openapi.yaml +var chaintracksOpenAPIYAML []byte + +const schemaRefPrefix = "#/components/schemas/" + +// OpenAPI Spec Merging +// +// This file implements runtime merging of Arcade's OpenAPI spec with the +// Chaintracks OpenAPI spec. When the chaintracks HTTP API is enabled, both +// specs are combined into a single document served at /docs/openapi.json. +// +// The merge process: +// 1. Loads Arcade's spec from swaggo-generated docs +// 2. Loads Chaintracks spec from embedded YAML file +// 3. Prefixes all chaintracks paths with "/chaintracks" +// 4. Prefixes chaintracks tags with "chaintracks-" to avoid conflicts +// 5. Prefixes chaintracks schema names with "Chaintracks" prefix +// 6. Updates all schema $ref references to use the new prefixed names +// +// This ensures both APIs are documented in a single Scalar UI interface. + +// mergeOpenAPISpecs merges the arcade and chaintracks OpenAPI specifications. +// It prefixes all chaintracks paths with the given pathPrefix. +func mergeOpenAPISpecs(arcadeJSON string, pathPrefix string) (string, error) { + arcadeSpec, chaintracksSpec, err := parseSpecs(arcadeJSON) + if err != nil { + return "", err + } + + arcadePaths := getOrCreateMap(arcadeSpec, "paths") + mergeChainstacksPaths(arcadePaths, chaintracksSpec, pathPrefix) + mergeChaintracksTags(arcadeSpec, chaintracksSpec) + mergeChaintracksSchemas(arcadeSpec, chaintracksSpec) + updatePrefixedPathTags(arcadePaths, pathPrefix) + + mergedJSON, err := json.MarshalIndent(arcadeSpec, "", " ") + if err != nil { + return "", fmt.Errorf("failed to marshal merged spec: %w", err) + } + + return string(mergedJSON), nil +} + +func parseSpecs(arcadeJSON string) (map[string]interface{}, map[string]interface{}, error) { + var arcadeSpec map[string]interface{} + if err := json.Unmarshal([]byte(arcadeJSON), &arcadeSpec); err != nil { + return nil, nil, fmt.Errorf("failed to parse arcade spec: %w", err) + } + + var chaintracksSpec map[string]interface{} + if err := yaml.Unmarshal(chaintracksOpenAPIYAML, &chaintracksSpec); err != nil { + return nil, nil, fmt.Errorf("failed to parse chaintracks spec: %w", err) + } + + return arcadeSpec, chaintracksSpec, nil +} + +func getOrCreateMap(parent map[string]interface{}, key string) map[string]interface{} { + m, ok := parent[key].(map[string]interface{}) + if !ok { + m = make(map[string]interface{}) + parent[key] = m + } + + return m +} + +func mergeChainstacksPaths(arcadePaths map[string]interface{}, chaintracksSpec map[string]interface{}, pathPrefix string) { + chaintracksPaths, ok := chaintracksSpec["paths"].(map[string]interface{}) + if !ok { + return + } + + for path, pathItem := range chaintracksPaths { + if isCDNOnlyPath(path) { + continue + } + + isLegacy := isLegacyPathItem(pathItem) + arcadePaths[buildPrefixedPath(path, pathPrefix, isLegacy)] = pathItem + } +} + +func isLegacyPathItem(pathItem interface{}) bool { + pathItemMap, ok := pathItem.(map[string]interface{}) + if !ok { + return false + } + + for _, operation := range pathItemMap { + if isLegacyOperation(operation) { + return true + } + } + + return false +} + +func isLegacyOperation(operation interface{}) bool { + opMap, ok := operation.(map[string]interface{}) + if !ok { + return false + } + + tags, ok := opMap["tags"].([]interface{}) + if !ok { + return false + } + + for _, tag := range tags { + if tagStr, ok := tag.(string); ok && tagStr == "Legacy" { + return true + } + } + + return false +} + +func buildPrefixedPath(path, pathPrefix string, isLegacy bool) string { + if isLegacy { + return pathPrefix + "/v1" + path + } + + return pathPrefix + path +} + +func mergeChaintracksTags(arcadeSpec, chaintracksSpec map[string]interface{}) { + chaintracksTags, hasTags := chaintracksSpec["tags"].([]interface{}) + if !hasTags { + return + } + + arcadeTags, _ := arcadeSpec["tags"].([]interface{}) + + for _, tag := range chaintracksTags { + tagMap, ok := tag.(map[string]interface{}) + if !ok { + continue + } + + if name, ok := tagMap["name"].(string); ok { + tagMap["name"] = prefixChaintracksTag(name) + } + + arcadeTags = append(arcadeTags, tagMap) + } + + arcadeSpec["tags"] = arcadeTags +} + +func prefixChaintracksTag(name string) string { + if name == "Legacy" { + return "chaintracks-v1" + } + + return "chaintracks-" + name +} + +func mergeChaintracksSchemas(arcadeSpec, chaintracksSpec map[string]interface{}) { + chaintracksComponents, ok := chaintracksSpec["components"].(map[string]interface{}) + if !ok { + return + } + + chaintracksSchemas, ok := chaintracksComponents["schemas"].(map[string]interface{}) + if !ok { + return + } + + arcadeComponents := getOrCreateMap(arcadeSpec, "components") + arcadeSchemas := getOrCreateMap(arcadeComponents, "schemas") + + for schemaName, schema := range chaintracksSchemas { + arcadeSchemas["Chaintracks"+schemaName] = schema + } +} + +func updatePrefixedPathTags(arcadePaths map[string]interface{}, pathPrefix string) { + for path, pathItem := range arcadePaths { + if !strings.HasPrefix(path, pathPrefix) { + continue + } + + pathItemMap, ok := pathItem.(map[string]interface{}) + if !ok { + continue + } + + for method, operation := range pathItemMap { + operationMap, ok := operation.(map[string]interface{}) + if !ok { + continue + } + + updateOperationTags(operationMap) + updateSchemaRefs(operationMap, "Chaintracks") + pathItemMap[method] = operationMap + } + } +} + +func updateOperationTags(operationMap map[string]interface{}) { + tags, ok := operationMap["tags"].([]interface{}) + if !ok { + return + } + + for i, tag := range tags { + tagStr, ok := tag.(string) + if !ok { + continue + } + + switch tagStr { + case "v2", "CDN": + tags[i] = "chaintracks-" + tagStr + case "Legacy": + tags[i] = "chaintracks-v1" + } + } +} + +// isCDNOnlyPath returns true if the path is a CDN-only endpoint that should be excluded. +// We exclude the CDN health check since arcade has its own /health endpoint. +func isCDNOnlyPath(path string) bool { + return path == "/health" // CDN health check - arcade has its own /health +} + +// updateSchemaRefs recursively updates schema references by adding a prefix. +func updateSchemaRefs(obj interface{}, prefix string) { + switch v := obj.(type) { + case map[string]interface{}: + for key, val := range v { + if key == "$ref" { + updateSchemaRef(v, key, val, prefix) + } else { + updateSchemaRefs(val, prefix) + } + } + case []interface{}: + for _, item := range v { + updateSchemaRefs(item, prefix) + } + } +} + +func updateSchemaRef(m map[string]interface{}, key string, val interface{}, prefix string) { + refStr, ok := val.(string) + if !ok || !strings.HasPrefix(refStr, schemaRefPrefix) { + return + } + + m[key] = schemaRefPrefix + prefix + refStr[len(schemaRefPrefix):] +} diff --git a/cmd/arcade/openapi_test.go b/cmd/arcade/openapi_test.go new file mode 100644 index 0000000..395ab27 --- /dev/null +++ b/cmd/arcade/openapi_test.go @@ -0,0 +1,96 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" +) + +const testArcadeSpec = `{ + "swagger": "2.0", + "info": { + "title": "Arcade API", + "version": "0.1.0" + }, + "paths": { + "/tx": { + "post": { + "tags": ["arcade"], + "summary": "Submit transaction" + } + } + }, + "tags": [ + {"name": "arcade", "description": "Arcade endpoints"} + ] +}` + +func mergeAndParse(t *testing.T) map[string]interface{} { + t.Helper() + + merged, err := mergeOpenAPISpecs(testArcadeSpec, "/chaintracks") + if err != nil { + t.Fatalf("Failed to merge specs: %v", err) + } + + var spec map[string]interface{} + if err := json.Unmarshal([]byte(merged), &spec); err != nil { + t.Fatalf("Failed to parse merged spec: %v", err) + } + + return spec +} + +func hasChaintracksPath(paths map[string]interface{}) bool { + for path := range paths { + if strings.HasPrefix(path, "/chaintracks") { + return true + } + } + + return false +} + +func hasPrefixedChaintracksTag(tags []interface{}) bool { + for _, tag := range tags { + tagMap, ok := tag.(map[string]interface{}) + if !ok { + continue + } + + name, ok := tagMap["name"].(string) + if ok && strings.HasPrefix(name, "chaintracks-") { + return true + } + } + + return false +} + +func TestMergeOpenAPISpecs(t *testing.T) { + spec := mergeAndParse(t) + + paths, ok := spec["paths"].(map[string]interface{}) + if !ok { + t.Fatal("paths not found in merged spec") + } + + if _, hasTx := paths["/tx"]; !hasTx { + t.Error("Arcade /tx path not found in merged spec") + } + + if !hasChaintracksPath(paths) { + t.Error("No chaintracks paths found with /chaintracks prefix") + } + + tags, ok := spec["tags"].([]interface{}) + if !ok || len(tags) < 2 { + t.Errorf("Expected at least 2 tags after merge, got %d", len(tags)) + } + + if !hasPrefixedChaintracksTag(tags) { + t.Error("No chaintracks- prefixed tags found") + } + + t.Logf("Successfully merged specs with %d total paths", len(paths)) +} diff --git a/cmd/arcade/static/chaintracks-openapi.yaml b/cmd/arcade/static/chaintracks-openapi.yaml new file mode 100644 index 0000000..11dcf0e --- /dev/null +++ b/cmd/arcade/static/chaintracks-openapi.yaml @@ -0,0 +1,870 @@ +openapi: 3.0.3 +info: + title: Chaintracks Server API + description: | + REST API for querying Bitcoin SV blockchain headers. + + ## Services + - **API Server** (port 3011): Real-time header queries and streaming + - **CDN Server** (port 3012): Static header files for bootstrap (optional) + + ## CDN Bootstrap Format + The CDN serves static header files compatible with the TypeScript chaintracks-server CDN format: + - `{network}NetBlockHeaders.json` - Metadata with file list + - `{network}Net_X.headers` - Binary header files (100k headers × 80 bytes = ~8MB each) + + Where `{network}` is `main` or `test`. + version: 2.0.0 +servers: + - url: / + description: API Server (default port 3011) +tags: + - name: v2 + description: Modern v2 API endpoints + - name: Legacy + description: Legacy v1 endpoints for backwards compatibility + - name: CDN + description: CDN static file server endpoints (port 3012, when enabled) +paths: + /v2/network: + get: + summary: Get blockchain network + description: Returns the network name (main or test) + tags: + - v2 + responses: + '200': + description: Successful response + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/SuccessResponse' + - type: object + properties: + value: + type: string + + /v2/tip: + get: + summary: Get chain tip header + description: Returns the full header of the current chain tip + tags: + - v2 + responses: + '200': + description: Successful response + headers: + Cache-Control: + schema: + type: string + description: Cache control header (no-cache) + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/SuccessResponse' + - type: object + properties: + value: + $ref: '#/components/schemas/BlockHeader' + '404': + description: Chain tip not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /v2/tip/stream: + get: + summary: Subscribe to tip updates (SSE) + description: Server-Sent Events stream for real-time chain tip updates + tags: + - v2 + responses: + '200': + description: SSE stream of tip updates + content: + text/event-stream: + schema: + type: string + description: JSON-encoded BlockHeader objects + + /v2/tip.bin: + get: + summary: Get chain tip as binary + description: Returns the chain tip as 80-byte binary data + tags: + - v2 + responses: + '200': + description: Binary header data + headers: + Cache-Control: + schema: + type: string + description: Cache control header (no-cache) + X-Block-Height: + schema: + type: integer + description: Block height + content: + application/octet-stream: + schema: + type: string + format: binary + '404': + description: Chain tip not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /v2/header/height/{height}: + get: + summary: Get header by height + description: Returns a block header for a specific height + tags: + - v2 + parameters: + - name: height + in: path + required: true + schema: + type: integer + format: uint32 + description: Block height + responses: + '200': + description: Successful response + headers: + Cache-Control: + schema: + type: string + description: Cache control header (varies based on height) + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/SuccessResponse' + - type: object + properties: + value: + $ref: '#/components/schemas/BlockHeader' + '400': + description: Invalid parameters + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Header not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /v2/header/height/{height}.bin: + get: + summary: Get header by height as binary + description: Returns a block header as 80-byte binary data + tags: + - v2 + parameters: + - name: height + in: path + required: true + schema: + type: integer + format: uint32 + description: Block height + responses: + '200': + description: Binary header data + headers: + Cache-Control: + schema: + type: string + description: Cache control header (varies based on height) + X-Block-Height: + schema: + type: integer + description: Block height + content: + application/octet-stream: + schema: + type: string + format: binary + '400': + description: Invalid parameters + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Header not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /v2/header/hash/{hash}: + get: + summary: Get header by hash + description: Returns a block header for a specific block hash + tags: + - v2 + parameters: + - name: hash + in: path + required: true + schema: + type: string + description: Block hash (hex string) + responses: + '200': + description: Successful response + headers: + Cache-Control: + schema: + type: string + description: Cache control header (varies based on height) + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/SuccessResponse' + - type: object + properties: + value: + $ref: '#/components/schemas/BlockHeader' + '400': + description: Invalid parameters + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Header not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /v2/header/hash/{hash}.bin: + get: + summary: Get header by hash as binary + description: Returns a block header as 80-byte binary data + tags: + - v2 + parameters: + - name: hash + in: path + required: true + schema: + type: string + description: Block hash (hex string) + responses: + '200': + description: Binary header data + headers: + Cache-Control: + schema: + type: string + description: Cache control header (varies based on height) + X-Block-Height: + schema: + type: integer + description: Block height + content: + application/octet-stream: + schema: + type: string + format: binary + '400': + description: Invalid parameters + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Header not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /v2/headers: + get: + summary: Get multiple headers + description: Returns multiple block headers as binary (80 bytes each) + tags: + - v2 + parameters: + - name: height + in: query + required: true + schema: + type: integer + format: uint32 + description: Starting block height + - name: count + in: query + required: true + schema: + type: integer + format: uint32 + description: Number of headers to retrieve + responses: + '200': + description: Binary header data + headers: + Cache-Control: + schema: + type: string + description: Cache control header (varies based on height) + content: + application/octet-stream: + schema: + type: string + format: binary + description: Concatenated block headers (80 bytes per header) + '400': + description: Invalid parameters + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /v2/headers.bin: + get: + summary: Get multiple headers as binary + description: Returns multiple block headers as binary with metadata headers + tags: + - v2 + parameters: + - name: height + in: query + required: true + schema: + type: integer + format: uint32 + description: Starting block height + - name: count + in: query + required: true + schema: + type: integer + format: uint32 + description: Number of headers to retrieve + responses: + '200': + description: Binary header data + headers: + Cache-Control: + schema: + type: string + description: Cache control header (varies based on height) + X-Start-Height: + schema: + type: integer + description: Starting block height + X-Header-Count: + schema: + type: integer + description: Number of headers returned + content: + application/octet-stream: + schema: + type: string + format: binary + description: Concatenated block headers (80 bytes per header) + '400': + description: Invalid parameters + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + # Legacy v1 endpoints (RPC-style API for backwards compatibility) + /getChain: + get: + summary: Get blockchain network (legacy) + description: Legacy endpoint - Returns the network name + tags: + - Legacy + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/LegacySuccessResponse' + + /getPresentHeight: + get: + summary: Get current height (legacy) + description: Legacy endpoint - Returns the current blockchain height + tags: + - Legacy + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/LegacySuccessResponse' + + /findChainTipHashHex: + get: + summary: Get chain tip hash (legacy) + description: Legacy endpoint - Returns the chain tip hash + tags: + - Legacy + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/LegacySuccessResponse' + '404': + description: Chain tip not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /findChainTipHeaderHex: + get: + summary: Get chain tip header (legacy) + description: Legacy endpoint - Returns the chain tip header + tags: + - Legacy + responses: + '200': + description: Successful response + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/LegacySuccessResponse' + - type: object + properties: + value: + $ref: '#/components/schemas/BlockHeader' + '404': + description: Chain tip not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /findHeaderHexForHeight: + get: + summary: Get header by height (legacy) + description: Legacy endpoint - Returns a header by height + tags: + - Legacy + parameters: + - name: height + in: query + required: true + schema: + type: integer + format: uint32 + description: Block height + responses: + '200': + description: Successful response + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/LegacySuccessResponse' + - type: object + properties: + value: + $ref: '#/components/schemas/BlockHeader' + '400': + description: Invalid parameters + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Header not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /findHeaderHexForBlockHash: + get: + summary: Get header by hash (legacy) + description: Legacy endpoint - Returns a header by block hash + tags: + - Legacy + parameters: + - name: hash + in: query + required: true + schema: + type: string + description: Block hash (hex string) + responses: + '200': + description: Successful response + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/LegacySuccessResponse' + - type: object + properties: + value: + $ref: '#/components/schemas/BlockHeader' + '400': + description: Invalid parameters + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Header not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /getHeaders: + get: + summary: Get multiple headers (legacy) + description: Legacy endpoint - Returns multiple headers as hex string + tags: + - Legacy + parameters: + - name: height + in: query + required: true + schema: + type: integer + format: uint32 + description: Starting block height + - name: count + in: query + required: true + schema: + type: integer + format: uint32 + description: Number of headers to retrieve + responses: + '200': + description: Successful response + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/LegacySuccessResponse' + - type: object + properties: + value: + type: string + description: Concatenated headers as hex string + '400': + description: Invalid parameters + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + # CDN Server endpoints (port 3012, when enabled) + # Note: These endpoints are served by a separate CDN server + /{network}NetBlockHeaders.json: + get: + summary: CDN metadata file + description: | + Returns metadata JSON describing available header files. + Served from CDN server (default port 3012). + + Example response: + ```json + { + "rootFolder": "", + "jsonFilename": "mainNetBlockHeaders.json", + "headersPerFile": 100000, + "files": [ + { + "chain": "main", + "count": 100000, + "fileHash": "abc123...", + "fileName": "mainNet_0.headers", + "firstHeight": 0, + "lastChainWork": "0x...", + "lastHash": "000000...", + "prevChainWork": "0x0", + "prevHash": "000000...", + "sourceUrl": "https://..." + } + ] + } + ``` + tags: + - CDN + parameters: + - name: network + in: path + required: true + schema: + type: string + enum: [main, test] + description: Network name (main or test) + responses: + '200': + description: CDN metadata + headers: + Cache-Control: + schema: + type: string + description: no-cache (metadata should be fetched fresh) + content: + application/json: + schema: + $ref: '#/components/schemas/CDNMetadata' + '404': + description: Metadata file not found + + /{network}Net_{index}.headers: + get: + summary: CDN header file + description: | + Returns binary header file containing up to 100,000 block headers. + Each header is 80 bytes, so a full file is approximately 8MB. + Served from CDN server (default port 3012). + + Supports HTTP Range requests for partial downloads. + tags: + - CDN + parameters: + - name: network + in: path + required: true + schema: + type: string + enum: [main, test] + description: Network name (main or test) + - name: index + in: path + required: true + schema: + type: integer + minimum: 0 + description: File index (0-based). File 0 contains headers 0-99999, file 1 contains 100000-199999, etc. + responses: + '200': + description: Binary header data + headers: + Cache-Control: + schema: + type: string + description: public, max-age=3600 (immutable historical data) + Accept-Ranges: + schema: + type: string + description: bytes (supports Range requests) + content: + application/octet-stream: + schema: + type: string + format: binary + description: Concatenated block headers (80 bytes per header, up to 100k headers) + '206': + description: Partial content (when Range header is used) + content: + application/octet-stream: + schema: + type: string + format: binary + '404': + description: Header file not found + + /health: + get: + summary: CDN health check + description: | + Health check endpoint for the CDN server. + Served from CDN server (default port 3012). + tags: + - CDN + responses: + '200': + description: CDN server is healthy + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: ok + service: + type: string + example: cdn + +components: + schemas: + SuccessResponse: + type: object + required: + - status + properties: + status: + type: string + enum: [success] + value: + description: Response payload (varies by endpoint) + + LegacySuccessResponse: + type: object + required: + - status + properties: + status: + type: string + enum: [success] + value: + description: Response payload (varies by endpoint) + + ErrorResponse: + type: object + required: + - status + - code + - description + properties: + status: + type: string + enum: [error] + code: + type: string + description: + type: string + + BlockHeader: + type: object + required: + - version + - previousHash + - merkleRoot + - time + - bits + - nonce + - height + - hash + properties: + version: + type: integer + format: int32 + description: Block version + previousHash: + type: string + description: Previous block hash + merkleRoot: + type: string + description: Merkle root hash + time: + type: integer + format: uint32 + description: Block timestamp (Unix time) + bits: + type: integer + format: uint32 + description: Difficulty target + nonce: + type: integer + format: uint32 + description: Nonce + height: + type: integer + format: uint32 + description: Block height in the chain + hash: + type: string + description: Block hash + + CDNMetadata: + type: object + required: + - rootFolder + - jsonFilename + - headersPerFile + - files + properties: + rootFolder: + type: string + description: Root folder path for header files + example: "" + jsonFilename: + type: string + description: Name of this metadata JSON file + example: "mainNetBlockHeaders.json" + headersPerFile: + type: integer + description: Number of headers per binary file (typically 100000) + example: 100000 + files: + type: array + items: + $ref: '#/components/schemas/CDNFileEntry' + description: List of available header files with metadata + + CDNFileEntry: + type: object + required: + - chain + - count + - fileHash + - fileName + - firstHeight + - lastChainWork + - lastHash + - prevChainWork + - prevHash + - sourceUrl + properties: + chain: + type: string + description: Network name (main or test) + example: "main" + count: + type: integer + description: Number of headers in this file + example: 100000 + fileHash: + type: string + description: SHA256 hash of the file contents + fileName: + type: string + description: Name of the binary header file + example: "mainNet_0.headers" + firstHeight: + type: integer + format: uint32 + description: Height of the first header in this file + example: 0 + lastChainWork: + type: string + description: Cumulative chainwork at the last header (hex string) + lastHash: + type: string + description: Hash of the last header in this file + prevChainWork: + type: string + description: Cumulative chainwork before the first header (hex string) + prevHash: + type: string + description: Hash of the block before the first header in this file + sourceUrl: + type: string + description: URL where this file can be downloaded + example: "https://chaintracks-cdn-us-1.bsvb.tech/mainNet_0.headers" diff --git a/docker-compose.yaml b/docker-compose.yaml index 83e7ef9..fb4cb5c 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -13,16 +13,28 @@ services: ports: - "3011:3011" volumes: - # Mount config file - - ./config.yaml:/app/config.yaml:ro # Persistent data directory - arcade-data:/data environment: - # Override config via environment variables + # Network configuration ARCADE_NETWORK: ${ARCADE_NETWORK:-main} - ARCADE_LOG_LEVEL: ${ARCADE_LOG_LEVEL:-info} + + # Storage paths ARCADE_STORAGE_PATH: /data + ARCADE_DATABASE_SQLITE_PATH: /data/arcade.db + ARCADE_CHAINTRACKS_STORAGE_PATH: /data/chaintracks + + # Teranode endpoints (production mainnet by default) + ARCADE_TERANODE_BROADCAST_URLS: ${ARCADE_TERANODE_BROADCAST_URLS:-https://teranode-eks-mainnet-eu-1-propagation.bsvb.tech,https://teranode-eks-mainnet-us-1-propagation.bsvb.tech} + ARCADE_TERANODE_DATAHUB_URLS: ${ARCADE_TERANODE_DATAHUB_URLS:-https://teranode-eks-mainnet-eu-1.bsvb.tech/api/v1} + ARCADE_TERANODE_AUTH_TOKEN: ${ARCADE_TERANODE_AUTH_TOKEN:-} # Required for authentication + + # Server configuration ARCADE_SERVER_ADDRESS: :3011 + ARCADE_LOG_LEVEL: ${ARCADE_LOG_LEVEL:-debug} + + # Optional: Enable chaintracks HTTP API (default: true) + ARCADE_CHAINTRACKS_SERVER_ENABLED: ${ARCADE_CHAINTRACKS_SERVER_ENABLED:-true} restart: unless-stopped healthcheck: test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3011/health"] diff --git a/go.mod b/go.mod index 3b54769..9708431 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/spf13/viper v1.21.0 github.com/swaggo/swag v1.16.6 github.com/valyala/fasthttp v1.69.0 + gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.45.0 ) @@ -264,7 +265,6 @@ require ( google.golang.org/protobuf v1.36.11 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/api v0.35.1 // indirect k8s.io/apimachinery v0.35.1 // indirect k8s.io/client-go v0.35.1 // indirect