Files
dchain/node/api_routes.go
vsecoder 1d9206494a feat(chain): multi-device registry (v2.2.0-alpha1)
PR #1 of the multi-device roadmap. Adds per-device X25519 keys registered
on-chain so senders can fan out envelopes across all of a recipient's
physical devices — fixes the single-device limitation where a second
phone / desktop loses messages as soon as the first one reads them.

Chain (blockchain/):
  - New event types LINK_DEVICE / UNLINK_DEVICE, signed by the identity's
    master Ed25519.
  - LinkDevicePayload {x25519_pub_key, device_name} +
    UnlinkDevicePayload {x25519_pub_key} on the wire.
  - State: prefixDevice + x25519_pub → DeviceRecord{owner, name,
    added_at, revoked_at?}; reverse index prefixDevicesByOwner for
    O(k) listing. Revoke is a soft-delete — the row stays as a visible
    tombstone so offline clients can detect their own revocation and
    wipe local state.
  - MaxDevicesPerOwner = 10 slot cap; MaxDeviceNameLen = 64.
  - Strict lowercase-hex validation on x25519_pub so clients can't
    desync on letter case.
  - Same-owner re-link is a rename/refresh (recreates reverse index too
    — needed after a revoke).
  - Chain.DevicesOf(master_pub) returns the active records; empty slice
    for legacy identities so senders can fall back to IdentityInfo.X25519Pub.

HTTP (node/):
  - GET /api/devices/{master_pub_or_addr} — returns {master_pub, count,
    devices[]}. Revoked records filtered out.
  - /api/identity/{pub} gains `device_count` so senders can decide
    upfront whether to fan out or take the legacy path.

Tests (blockchain/devices_test.go):
  - Happy paths (1, 3 devices), foreign-owner rejection, same-owner
    refresh after revoke, unlink removes from active set,
    foreign-signer unlink rejection, idempotent double-unlink,
    malformed pub/name rejection, MaxDevices cap + recovery after
    unlink frees a slot, empty list for unknown master.

Also in this commit:
  - deploy/single/join.sh — convenience script operators have been
    iterating on in this session (joiner-node bring-up + firewall
    port patching + Caddy opt-out).
  - client-app/app.json — `usesCleartextTraffic: true` on Android so
    installed APKs can talk to http:// dev nodes without TLS.

See docs/ROADMAP.md for PRs #2..#4 (client fan-out, pairing flow,
desktop Electron shell).
2026-04-22 16:20:07 +03:00

269 lines
12 KiB
Go

// Package node - chain explorer HTTP API and minimal web UI.
package node
import (
"net/http"
"go-blockchain/blockchain"
)
// ConnectedPeerRef is an entry returned by /api/peers — one currently-connected
// libp2p peer. Mirrors p2p.ConnectedConnectedPeerRef but kept in the node package
// so api routes don't pull the p2p package directly.
type ConnectedPeerRef struct {
ID string `json:"id"`
Addrs []string `json:"addrs"`
// Version is the peer's last-seen version announce (from gossipsub topic
// dchain/version/v1). Empty when the peer hasn't announced yet — either
// it's running an older binary without gossip, or it hasn't reached its
// first publish tick (up to 60s after connect).
Version *PeerVersionRef `json:"version,omitempty"`
}
// PeerVersionRef mirrors p2p.PeerVersion in a package-local type so
// api_routes doesn't import p2p directly.
type PeerVersionRef struct {
Tag string `json:"tag"`
Commit string `json:"commit"`
ProtocolVersion int `json:"protocol_version"`
Timestamp int64 `json:"timestamp"`
ReceivedAt string `json:"received_at,omitempty"`
}
// NativeContractInfo is the shape ExplorerQuery.NativeContracts returns.
// Passed from main.go (which has the blockchain package imported) to the
// well-known endpoint below so it can merge native contracts with WASM ones.
type NativeContractInfo struct {
ContractID string
ABIJson string
}
// ExplorerQuery holds all functions the explorer API needs to read chain state
// and accept new transactions.
type ExplorerQuery struct {
GetBlock func(index uint64) (*blockchain.Block, error)
GetTx func(txID string) (*blockchain.TxRecord, error)
AddressToPubKey func(addr string) (string, error)
Balance func(pubKey string) (uint64, error)
Reputation func(pubKey string) (blockchain.RepStats, error)
WalletBinding func(pubKey string) (string, error)
TxsByAddress func(pubKey string, limit, offset int) ([]*blockchain.TxRecord, error)
RecentBlocks func(limit int) ([]*blockchain.Block, error)
RecentTxs func(limit int) ([]*blockchain.TxRecord, error)
NetStats func() (blockchain.NetStats, error)
RegisteredRelays func() ([]blockchain.RegisteredRelayInfo, error)
IdentityInfo func(pubKeyOrAddr string) (*blockchain.IdentityInfo, error)
// DevicesOf (multi-device v2.2.0) returns the identity's non-revoked
// device records. Empty slice if the identity hasn't linked any yet
// — senders fall back to IdentityInfo.X25519Pub for legacy clients.
DevicesOf func(masterPub string) ([]blockchain.DeviceRecord, error)
ValidatorSet func() ([]string, error)
SubmitTx func(tx *blockchain.Transaction) error
// ConnectedPeers (optional) returns the local libp2p view of currently
// connected peers. Used by /api/peers and /api/network-info so new nodes
// can bootstrap from any existing peer's view of the network. May be nil
// if the node binary is built without p2p (tests).
ConnectedPeers func() []ConnectedPeerRef
// ChainID (optional) returns a stable identifier for this chain so a
// joiner can sanity-check it's syncing from the right network.
ChainID func() string
// NativeContracts (optional) returns the list of built-in Go contracts
// registered on this node. These appear in /api/well-known-contracts
// alongside WASM contracts, so the client doesn't need to distinguish.
NativeContracts func() []NativeContractInfo
GetContract func(contractID string) (*blockchain.ContractRecord, error)
GetContracts func() ([]blockchain.ContractRecord, error)
GetContractState func(contractID, key string) ([]byte, error)
GetContractLogs func(contractID string, limit int) ([]blockchain.ContractLogEntry, error)
Stake func(pubKey string) (uint64, error)
GetToken func(tokenID string) (*blockchain.TokenRecord, error)
GetTokens func() ([]blockchain.TokenRecord, error)
TokenBalance func(tokenID, pubKey string) (uint64, error)
GetNFT func(nftID string) (*blockchain.NFTRecord, error)
GetNFTs func() ([]blockchain.NFTRecord, error)
NFTsByOwner func(ownerPub string) ([]blockchain.NFTRecord, error)
// Events is the SSE hub for the live event stream. Optional — if nil the
// /api/events endpoint returns 501 Not Implemented.
Events *SSEHub
// WS is the websocket hub for low-latency push to mobile/desktop clients.
// Optional — if nil the /api/ws endpoint returns 501.
WS *WSHub
}
// ExplorerRouteFlags toggles the optional HTML frontend surfaces. API
// endpoints (/api/*) always register — these flags only affect static pages.
type ExplorerRouteFlags struct {
// DisableUI suppresses the embedded block explorer at `/`, `/address`,
// `/tx`, `/node`, `/relays`, `/validators`, `/contract`, `/tokens`,
// `/token`, and their `/assets/explorer/*.js|css` dependencies. Useful
// for JSON-API-only deployments (headless nodes, mobile-backend nodes).
DisableUI bool
// DisableSwagger suppresses `/swagger` and `/swagger/openapi.json`.
// Useful for hardened private deployments where even API documentation
// shouldn't be exposed. Does NOT affect the JSON API itself.
DisableSwagger bool
}
// RegisterExplorerRoutes adds all explorer API, chain API and docs routes to mux.
// The variadic flags parameter is optional — passing none (or an empty struct)
// registers the full surface (UI + Swagger + JSON API) for backwards compatibility.
func RegisterExplorerRoutes(mux *http.ServeMux, q ExplorerQuery, flags ...ExplorerRouteFlags) {
var f ExplorerRouteFlags
if len(flags) > 0 {
f = flags[0]
}
if !f.DisableUI {
registerExplorerPages(mux)
}
registerExplorerAPI(mux, q)
registerChainAPI(mux, q)
registerContractAPI(mux, q)
registerWellKnownAPI(mux, q)
registerWellKnownVersionAPI(mux, q)
registerUpdateCheckAPI(mux, q)
registerOnboardingAPI(mux, q)
registerTokenAPI(mux, q)
if !f.DisableSwagger {
registerSwaggerRoutes(mux)
}
}
// RegisterRelayRoutes adds relay mailbox HTTP endpoints to mux.
// Call this after RegisterExplorerRoutes with the relay config.
func RegisterRelayRoutes(mux *http.ServeMux, rc RelayConfig) {
if rc.Mailbox == nil {
return
}
registerRelayRoutes(mux, rc)
}
func registerExplorerPages(mux *http.ServeMux) {
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
serveExplorerIndex(w, r)
})
mux.HandleFunc("/address", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/address" {
http.NotFound(w, r)
return
}
serveExplorerAddressPage(w, r)
})
mux.HandleFunc("/tx", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/tx" {
http.NotFound(w, r)
return
}
serveExplorerTxPage(w, r)
})
mux.HandleFunc("/node", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/node" {
http.NotFound(w, r)
return
}
serveExplorerNodePage(w, r)
})
mux.HandleFunc("/relays", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/relays" {
http.NotFound(w, r)
return
}
serveExplorerRelaysPage(w, r)
})
mux.HandleFunc("/validators", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/validators" {
http.NotFound(w, r)
return
}
serveExplorerValidatorsPage(w, r)
})
mux.HandleFunc("/assets/explorer/style.css", serveExplorerCSS)
mux.HandleFunc("/assets/explorer/common.js", serveExplorerCommonJS)
mux.HandleFunc("/assets/explorer/app.js", serveExplorerJS)
mux.HandleFunc("/assets/explorer/address.js", serveExplorerAddressJS)
mux.HandleFunc("/assets/explorer/tx.js", serveExplorerTxJS)
mux.HandleFunc("/assets/explorer/node.js", serveExplorerNodeJS)
mux.HandleFunc("/assets/explorer/relays.js", serveExplorerRelaysJS)
mux.HandleFunc("/assets/explorer/validators.js", serveExplorerValidatorsJS)
mux.HandleFunc("/contract", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/contract" {
http.NotFound(w, r)
return
}
serveExplorerContractPage(w, r)
})
mux.HandleFunc("/assets/explorer/contract.js", serveExplorerContractJS)
mux.HandleFunc("/tokens", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/tokens" {
http.NotFound(w, r)
return
}
serveExplorerTokensPage(w, r)
})
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/token" {
http.NotFound(w, r)
return
}
serveExplorerTokenPage(w, r)
})
mux.HandleFunc("/assets/explorer/tokens.js", serveExplorerTokensJS)
mux.HandleFunc("/assets/explorer/token.js", serveExplorerTokenJS)
}
func registerExplorerAPI(mux *http.ServeMux, q ExplorerQuery) {
mux.HandleFunc("/api/netstats", apiNetStats(q))
mux.HandleFunc("/api/blocks", apiRecentBlocks(q))
mux.HandleFunc("/api/txs/recent", apiRecentTxs(q)) // GET /api/txs/recent?limit=20
mux.HandleFunc("/api/block/", apiBlock(q)) // GET /api/block/{index}
mux.HandleFunc("/api/tx/", apiTxByID(q)) // GET /api/tx/{txid}
mux.HandleFunc("/api/address/", apiAddress(q)) // GET /api/address/{addr}
mux.HandleFunc("/api/node/", apiNode(q)) // GET /api/node/{pubkey|DC...}
mux.HandleFunc("/api/relays", apiRelays(q)) // GET /api/relays
mux.HandleFunc("/api/identity/", apiIdentity(q)) // GET /api/identity/{pubkey|addr}
mux.HandleFunc("/api/devices/", apiDevices(q)) // GET /api/devices/{master_pub} — multi-device registry (v2.2.0)
mux.HandleFunc("/api/validators", apiValidators(q))// GET /api/validators
mux.HandleFunc("/api/tx", withWriteTokenGuard(withSubmitTxGuards(apiSubmitTx(q)))) // POST /api/tx (body size + per-IP rate limit + optional token gate)
// Live event stream (SSE) — GET /api/events
mux.HandleFunc("/api/events", func(w http.ResponseWriter, r *http.Request) {
if q.Events == nil {
http.Error(w, "event stream not available", http.StatusNotImplemented)
return
}
q.Events.ServeHTTP(w, r)
})
// WebSocket gateway — GET /api/ws (upgrades to ws://). Low-latency push
// for clients that would otherwise poll balance/inbox/contacts.
mux.HandleFunc("/api/ws", func(w http.ResponseWriter, r *http.Request) {
if q.WS == nil {
http.Error(w, "websocket not available", http.StatusNotImplemented)
return
}
q.WS.ServeHTTP(w, r)
})
// Prometheus exposition endpoint — scraped by a Prometheus server. Has
// no auth; operators running this in public should put the node behind
// a reverse proxy that restricts /metrics to trusted scrapers only.
mux.HandleFunc("/metrics", metricsHandler)
}
func registerTokenAPI(mux *http.ServeMux, q ExplorerQuery) {
mux.HandleFunc("/api/tokens", apiTokens(q)) // GET /api/tokens
mux.HandleFunc("/api/tokens/", apiTokenByID(q)) // GET /api/tokens/{id} and /api/tokens/{id}/balance/{pubkey}
mux.HandleFunc("/api/nfts", apiNFTs(q)) // GET /api/nfts
mux.HandleFunc("/api/nfts/", apiNFTByID(q)) // GET /api/nfts/{id} and /api/nfts/owner/{pubkey}
}
func registerChainAPI(mux *http.ServeMux, q ExplorerQuery) {
// Similar to blockchain APIs, but only with features supported by this chain.
mux.HandleFunc("/v2/chain/accounts/", apiV2ChainAccountTransactions(q)) // GET /v2/chain/accounts/{account_id}/transactions
mux.HandleFunc("/v2/chain/transactions/", apiV2ChainTxByID(q)) // GET /v2/chain/transactions/{tx_id}
mux.HandleFunc("/v2/chain/transactions/draft", apiV2ChainDraftTx()) // POST /v2/chain/transactions/draft
mux.HandleFunc("/v2/chain/transactions", withWriteTokenGuard(withSubmitTxGuards(apiV2ChainSendTx(q)))) // POST /v2/chain/transactions (body size + rate limit + optional token gate)
}