chore: initial commit for v0.0.1

DChain single-node blockchain + React Native messenger client.

Core:
- PBFT consensus with multi-sig validator admission + equivocation slashing
- BadgerDB + schema migration scaffold (CurrentSchemaVersion=0)
- libp2p gossipsub (tx/v1, blocks/v1, relay/v1, version/v1)
- Native Go contracts (username_registry) alongside WASM (wazero)
- WebSocket gateway with topic-based fanout + Ed25519-nonce auth
- Relay mailbox with NaCl envelope encryption (X25519 + Ed25519)
- Prometheus /metrics, per-IP rate limit, body-size cap

Deployment:
- Single-node compose (deploy/single/) with Caddy TLS + optional Prometheus
- 3-node dev compose (docker-compose.yml) with mocked internet topology
- 3-validator prod compose (deploy/prod/) for federation
- Auto-update from Gitea via /api/update-check + systemd timer
- Build-time version injection (ldflags → node --version)
- UI / Swagger toggle flags (DCHAIN_DISABLE_UI, DCHAIN_DISABLE_SWAGGER)

Client (client-app/):
- Expo / React Native / NativeWind
- E2E NaCl encryption, typing indicator, contact requests
- Auto-discovery of canonical contracts, chain_id aware, WS reconnect on node switch

Documentation:
- README.md, CHANGELOG.md, CONTEXT.md
- deploy/single/README.md with 6 operator scenarios
- deploy/UPDATE_STRATEGY.md with 4-layer forward-compat design
- docs/contracts/*.md per contract
This commit is contained in:
vsecoder
2026-04-17 14:16:44 +03:00
commit 7e7393e4f8
196 changed files with 55947 additions and 0 deletions

201
node/api_update_check.go Normal file
View File

@@ -0,0 +1,201 @@
// Package node — /api/update-check endpoint.
//
// What this does
// ──────────────
// Polls the configured release source (typically a Gitea `/api/v1/repos/
// {owner}/{repo}/releases/latest` URL) and compares its answer with the
// binary's own build-time version. Returns:
//
// {
// "current": { "tag": "v0.5.0", "commit": "abc1234", "date": "..." },
// "latest": { "tag": "v0.5.1", "commit": "def5678", "url": "...", "published_at": "..." },
// "update_available": true,
// "checked_at": "2026-04-17T09:45:12Z"
// }
//
// When unconfigured (empty DCHAIN_UPDATE_SOURCE_URL), responds 503 with a
// hint pointing at the env var. Never blocks for more than ~5 seconds
// (HTTP timeout); upstream failures are logged and surfaced as 502.
//
// Cache
// ─────
// Hammering a public Gitea instance every time an operator curls this
// endpoint would be rude. We cache the latest-release lookup for 15 minutes
// in memory. The update script in deploy/single/update.sh honors this cache
// by reading /api/update-check once per run — so a typical hourly timer +
// 15-min cache means at most 4 upstream hits per node per hour, usually 1.
//
// Configuration
// ─────────────
// DCHAIN_UPDATE_SOURCE_URL — full URL of the Gitea latest-release API.
// Example:
// https://gitea.example.com/api/v1/repos/dchain/dchain/releases/latest
// DCHAIN_UPDATE_SOURCE_TOKEN — optional Gitea PAT for private repos.
//
// These are read by cmd/node/main.go and handed to SetUpdateSource below.
package node
import (
"encoding/json"
"fmt"
"io"
"net/http"
"sync"
"time"
"go-blockchain/node/version"
)
const (
updateCacheTTL = 15 * time.Minute
updateTimeout = 5 * time.Second
)
var (
updateMu sync.RWMutex
updateSourceURL string
updateSourceToken string
updateCache *updateCheckResponse
updateCacheAt time.Time
)
// SetUpdateSource configures where /api/update-check should poll. Called
// once at node startup from cmd/node/main.go after flag parsing. Empty url
// leaves the endpoint in "unconfigured" mode (503).
func SetUpdateSource(url, token string) {
updateMu.Lock()
defer updateMu.Unlock()
updateSourceURL = url
updateSourceToken = token
// Clear cache on config change (mostly a test-time concern).
updateCache = nil
updateCacheAt = time.Time{}
}
// giteaRelease is the subset of the Gitea release JSON we care about.
// Gitea's schema is a superset of GitHub's, so the same shape works for
// github.com/api/v3 too — operators can point at either.
type giteaRelease struct {
TagName string `json:"tag_name"`
TargetCommit string `json:"target_commitish"`
HTMLURL string `json:"html_url"`
PublishedAt string `json:"published_at"`
Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"`
}
type updateCheckResponse struct {
Current map[string]string `json:"current"`
Latest *latestRef `json:"latest,omitempty"`
UpdateAvailable bool `json:"update_available"`
CheckedAt string `json:"checked_at"`
Source string `json:"source,omitempty"`
}
type latestRef struct {
Tag string `json:"tag"`
Commit string `json:"commit,omitempty"`
URL string `json:"url,omitempty"`
PublishedAt string `json:"published_at,omitempty"`
}
func registerUpdateCheckAPI(mux *http.ServeMux, q ExplorerQuery) {
mux.HandleFunc("/api/update-check", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonErr(w, fmt.Errorf("method not allowed"), 405)
return
}
updateMu.RLock()
src := updateSourceURL
tok := updateSourceToken
cached := updateCache
cachedAt := updateCacheAt
updateMu.RUnlock()
if src == "" {
jsonErr(w,
fmt.Errorf("update source not configured — set DCHAIN_UPDATE_SOURCE_URL to a Gitea /api/v1/repos/{owner}/{repo}/releases/latest URL"),
http.StatusServiceUnavailable)
return
}
// Serve fresh cache without hitting upstream.
if cached != nil && time.Since(cachedAt) < updateCacheTTL {
jsonOK(w, cached)
return
}
// Fetch upstream.
latest, err := fetchLatestRelease(r.Context(), src, tok)
if err != nil {
jsonErr(w, fmt.Errorf("upstream check failed: %w", err), http.StatusBadGateway)
return
}
resp := &updateCheckResponse{
Current: version.Info(),
CheckedAt: time.Now().UTC().Format(time.RFC3339),
Source: src,
}
if latest != nil {
resp.Latest = &latestRef{
Tag: latest.TagName,
Commit: latest.TargetCommit,
URL: latest.HTMLURL,
PublishedAt: latest.PublishedAt,
}
resp.UpdateAvailable = latest.TagName != "" &&
latest.TagName != version.Tag &&
!latest.Draft &&
!latest.Prerelease
}
updateMu.Lock()
updateCache = resp
updateCacheAt = time.Now()
updateMu.Unlock()
jsonOK(w, resp)
})
}
// fetchLatestRelease performs the actual HTTP call with a short timeout.
// Returns nil, nil when the upstream replies 404 (no releases yet) — this
// is not an error condition, the operator just hasn't published anything
// yet. All other non-2xx are returned as errors.
func fetchLatestRelease(ctx interface{ Deadline() (time.Time, bool) }, url, token string) (*giteaRelease, error) {
// We only use ctx for cancellation type-matching; the actual deadline
// comes from updateTimeout below. Tests pass a context.Background().
_ = ctx
client := &http.Client{Timeout: updateTimeout}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
if token != "" {
req.Header.Set("Authorization", "token "+token)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "dchain-node/"+version.Tag)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, nil // no releases yet
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return nil, fmt.Errorf("http %d: %s", resp.StatusCode, string(b))
}
var rel giteaRelease
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
return &rel, nil
}