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).
269 lines
12 KiB
Go
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)
|
|
}
|