mirror of
https://github.com/luxfi/chains.git
synced 2026-09-24 00:26:00 +00:00
A plugin is its own binary; the daemon that loads it has no business being linked into it. Twelve VMs reached into github.com/luxfi/node for four things, and each one now programs against a home that is actually its own: - fee admission (Policy, FlatPolicy, NoUserTxPolicy, Validate) joins the settlement half already in chains/fee. One fee package, and the nodefee alias that marked the split is gone. - the cross-chain artifact types move to chains/artifacts. - the factory the daemon registers is luxfi/vm/manager.Factory, and the version types are luxfi/version. Both are ordinary modules the daemon reads too, so a VM imports them directly rather than through anything of ours. The GPU switch is deleted, not moved. It was a global set during daemon startup that a separate process could never observe, so every plugin read its default; FHEOptions.Backend is the switch that was really there. go list -deps names no luxfi/node for aivm, bridgevm, dexvm, evm, fhevm, graphvm, identityvm, keyvm, mpcvm, quantumvm, schain or zkvm, and the built plugin binaries carry no such string. oraclevm and relayvm still do: they are re-export shims over luxfi/oracle and luxfi/relay, which build their fee policies upstream. Their tests keep naming the daemon's fee package because that is the type their value has.
353 lines
11 KiB
Go
353 lines
11 KiB
Go
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
// Package graphvm implements the Graph VM (G-Chain) — a shared GraphQL database
|
|
// across all Lux chains. Any chain's state is queryable through a unified
|
|
// GraphQL endpoint.
|
|
package graphvm
|
|
|
|
import (
|
|
"context"
|
|
"crypto/subtle"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/luxfi/chains/fee"
|
|
"github.com/luxfi/database"
|
|
"github.com/luxfi/ids"
|
|
"github.com/luxfi/log"
|
|
"github.com/luxfi/runtime"
|
|
vmcore "github.com/luxfi/vm"
|
|
"github.com/luxfi/vm/chain"
|
|
"github.com/luxfi/warp"
|
|
|
|
"github.com/luxfi/version"
|
|
)
|
|
|
|
var (
|
|
_ chain.ChainVM = (*VM)(nil)
|
|
|
|
Version = &version.Semantic{
|
|
Major: 1,
|
|
Minor: 0,
|
|
Patch: 0,
|
|
}
|
|
|
|
errNoAppProtocol = errors.New("graphvm: this chain has no app protocol")
|
|
|
|
// errEmptyAPIKey refuses a configuration that would admit an empty token.
|
|
// subtle.ConstantTimeCompare reports equal for two zero-length slices, so a
|
|
// configured "" matches the token in `Authorization: Bearer ` — auth on,
|
|
// everyone in. A chain refuses to boot rather than serve that.
|
|
errEmptyAPIKey = errors.New("graphvm: requireAuth with an empty api key")
|
|
|
|
// errReadOnlyChain is the ONE predicate. G-Chain indexes what other chains
|
|
// have already agreed, so it holds no state of its own to agree on and never
|
|
// advances past genesis. BuildBlock declines by it, ParseBlock refuses any
|
|
// bytes but genesis by it, and Verify/Accept refuse any block but genesis by
|
|
// it — because a chain that admits what it cannot build has a gap between
|
|
// admission and consensus, and that gap is where a peer parks a block whose
|
|
// parent no one can resolve.
|
|
errReadOnlyChain = errors.New("graphvm: this chain indexes other chains and builds no blocks")
|
|
)
|
|
|
|
// GConfig contains VM configuration. Every field here is read; a knob that
|
|
// changes nothing is worse than no knob, because it reads as a control.
|
|
type GConfig struct {
|
|
// Query bounds
|
|
MaxQueryDepth int `json:"maxQueryDepth"`
|
|
QueryTimeoutMs int `json:"queryTimeoutMs"`
|
|
MaxResultSize int `json:"maxResultSize"`
|
|
|
|
// Authentication
|
|
RequireAuth bool `json:"requireAuth"`
|
|
APIKeys []string `json:"apiKeys"`
|
|
}
|
|
|
|
// VM implements the chain.ChainVM interface for the Graph Chain (G-Chain)
|
|
type VM struct {
|
|
rt *runtime.Runtime
|
|
db database.Database
|
|
config GConfig
|
|
toEngine chan<- vmcore.Message
|
|
appSender warp.Sender
|
|
|
|
// genesis is the whole of this chain's consensus state: it is the first
|
|
// block, the last-accepted block and the preferred block, permanently. One
|
|
// value, so the three can never disagree.
|
|
genesis *Block
|
|
|
|
// queries answers /graphql against the chain database.
|
|
queries *QueryExecutor
|
|
|
|
// G-Chain is read-only; the policy refuses all user-tx at the boundary.
|
|
// See feegate.go.
|
|
feePolicy fee.Policy
|
|
}
|
|
|
|
// Initialize implements the common.VM interface
|
|
func (vm *VM) Initialize(ctx context.Context, vmInit vmcore.Init) error {
|
|
vm.rt = vmInit.Runtime
|
|
vm.db = vmInit.DB
|
|
vm.toEngine = vmInit.ToEngine
|
|
vm.appSender = vmInit.Sender
|
|
|
|
if len(vmInit.Config) > 0 {
|
|
if err := json.Unmarshal(vmInit.Config, &vm.config); err != nil {
|
|
return fmt.Errorf("failed to parse config: %w", err)
|
|
}
|
|
}
|
|
if vm.config.RequireAuth {
|
|
if len(vm.config.APIKeys) == 0 {
|
|
return errEmptyAPIKey
|
|
}
|
|
for _, k := range vm.config.APIKeys {
|
|
if k == "" {
|
|
return errEmptyAPIKey
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fee gate: read-only chain → NoUserTxPolicy (refuses all user-tx).
|
|
vm.feePolicy = newFeePolicy()
|
|
if err := fee.Validate(vm.feePolicy); err != nil {
|
|
return fmt.Errorf("feepolicy: %w", err)
|
|
}
|
|
|
|
vm.queries = NewQueryExecutor(vm.db, &vm.config)
|
|
|
|
// The consensus engine REQUIRES a resolvable last-accepted block at boot:
|
|
// the ZAP VM server calls LastAccepted() then GetBlock(lastAccepted) inside
|
|
// Initialize, and a miss there is the "get last accepted block: not
|
|
// implemented" failure that fails the node's G-Chain health check.
|
|
vm.genesis = newBlock(vm, vmInit.Genesis)
|
|
|
|
if logger, ok := vm.rt.Log.(log.Logger); ok {
|
|
logger.Info("initialized Graph VM",
|
|
log.Reflect("version", Version),
|
|
log.String("genesisBlockID", vm.genesis.ID().String()),
|
|
)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// SetState implements the common.VM interface
|
|
func (vm *VM) SetState(ctx context.Context, state uint32) error {
|
|
return nil
|
|
}
|
|
|
|
// Shutdown implements the common.VM interface
|
|
func (vm *VM) Shutdown(context.Context) error {
|
|
if vm.db != nil {
|
|
return vm.db.Close()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Version implements the common.VM interface
|
|
func (vm *VM) Version(context.Context) (string, error) {
|
|
return Version.String(), nil
|
|
}
|
|
|
|
// CreateHandlers implements the common.VM interface.
|
|
//
|
|
// The node mounts each key under /v1/chain/<chainID> and matches that full path
|
|
// EXACTLY, then hands the handler the request with the path it arrived on. A
|
|
// handler that dispatches on r.URL.Path therefore never recognizes anything.
|
|
// The key IS the route; one handler per key.
|
|
func (vm *VM) CreateHandlers(context.Context) (map[string]http.Handler, error) {
|
|
return map[string]http.Handler{"/graphql": vm.graphql()}, nil
|
|
}
|
|
|
|
// NewHTTPHandler returns the same one route, mounted by path.
|
|
func (vm *VM) NewHTTPHandler(ctx context.Context) (http.Handler, error) {
|
|
mux := http.NewServeMux()
|
|
mux.Handle("/graphql", vm.graphql())
|
|
return mux, nil
|
|
}
|
|
|
|
// graphql builds the query endpoint. Authentication is decided here, once, so
|
|
// there is no second door onto the executor.
|
|
func (vm *VM) graphql() http.Handler {
|
|
var h http.Handler = &queryHandler{vm: vm}
|
|
if vm.config.RequireAuth {
|
|
h = authenticated(h, vm.config.APIKeys)
|
|
}
|
|
return h
|
|
}
|
|
|
|
// HealthCheck implements the health.Checker interface
|
|
func (vm *VM) HealthCheck(context.Context) (chain.HealthResult, error) {
|
|
return chain.HealthResult{
|
|
Healthy: true,
|
|
Details: map[string]string{
|
|
"version": Version.String(),
|
|
"state": "active",
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// Connected implements the validators.Connector interface
|
|
func (vm *VM) Connected(ctx context.Context, nodeID ids.NodeID, nodeVersion *chain.VersionInfo) error {
|
|
return nil
|
|
}
|
|
|
|
// Disconnected implements the validators.Connector interface
|
|
func (vm *VM) Disconnected(ctx context.Context, nodeID ids.NodeID) error {
|
|
return nil
|
|
}
|
|
|
|
// Request implements the common.AppHandler interface
|
|
func (vm *VM) Request(ctx context.Context, nodeID ids.NodeID, requestID uint32, deadline time.Time, request []byte) error {
|
|
return errNoAppProtocol
|
|
}
|
|
|
|
// RequestFailed implements the common.AppHandler interface
|
|
func (vm *VM) RequestFailed(ctx context.Context, nodeID ids.NodeID, requestID uint32, appErr *warp.Error) error {
|
|
return nil
|
|
}
|
|
|
|
// Response implements the common.AppHandler interface
|
|
func (vm *VM) Response(ctx context.Context, nodeID ids.NodeID, requestID uint32, response []byte) error {
|
|
return nil
|
|
}
|
|
|
|
// Gossip implements the common.AppHandler interface
|
|
func (vm *VM) Gossip(ctx context.Context, nodeID ids.NodeID, msg []byte) error {
|
|
return nil
|
|
}
|
|
|
|
// CrossChainRequest implements the common.VM interface
|
|
func (vm *VM) CrossChainRequest(ctx context.Context, chainID ids.ID, requestID uint32, deadline time.Time, msg []byte) error {
|
|
return nil
|
|
}
|
|
|
|
// CrossChainRequestFailed implements the common.VM interface
|
|
func (vm *VM) CrossChainRequestFailed(ctx context.Context, chainID ids.ID, requestID uint32, appErr *warp.Error) error {
|
|
return nil
|
|
}
|
|
|
|
// CrossChainResponse implements the common.VM interface
|
|
func (vm *VM) CrossChainResponse(ctx context.Context, chainID ids.ID, requestID uint32, msg []byte) error {
|
|
return nil
|
|
}
|
|
|
|
// WaitForEvent waits out the context and reports nothing, because this chain
|
|
// has nothing to report: it never builds a block, so there is never news of one
|
|
// to send. A latch here would wake a builder that declines, which is why this
|
|
// is not the frozen WaitForEvent the other chains had — those had work and no
|
|
// way to say so. See errReadOnlyChain.
|
|
func (vm *VM) WaitForEvent(ctx context.Context) (vmcore.Message, error) {
|
|
<-ctx.Done()
|
|
return vmcore.Message{}, ctx.Err()
|
|
}
|
|
|
|
// BuildBlock implements the chain.ChainVM interface, by declining. See
|
|
// errReadOnlyChain.
|
|
func (vm *VM) BuildBlock(ctx context.Context) (chain.Block, error) {
|
|
return nil, errReadOnlyChain
|
|
}
|
|
|
|
// ParseBlock implements the chain.ChainVM interface. The only block this chain
|
|
// has is genesis, so those are the only bytes that name a block of it: anything
|
|
// else is refused here rather than admitted, verified, accepted, and then found
|
|
// unresolvable by the next node that boots.
|
|
func (vm *VM) ParseBlock(ctx context.Context, blockBytes []byte) (chain.Block, error) {
|
|
return parseBlock(vm, blockBytes)
|
|
}
|
|
|
|
// GetBlock implements the chain.ChainVM interface. The G-Chain has exactly one
|
|
// block — genesis — which is permanently the accepted frontier; any other ID is
|
|
// unknown. Returning database.ErrNotFound (not a "not implemented") lets the ZAP
|
|
// VM server map a genuine miss to the wire NotFound code.
|
|
func (vm *VM) GetBlock(ctx context.Context, blkID ids.ID) (chain.Block, error) {
|
|
if blkID != vm.genesis.ID() {
|
|
return nil, database.ErrNotFound
|
|
}
|
|
return vm.genesis, nil
|
|
}
|
|
|
|
// SetPreference implements the chain.ChainVM interface. Preferring a block this
|
|
// chain does not have is a caller error, not a value to store.
|
|
func (vm *VM) SetPreference(ctx context.Context, blkID ids.ID) error {
|
|
if blkID != vm.genesis.ID() {
|
|
return errReadOnlyChain
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// LastAccepted implements the chain.ChainVM interface.
|
|
func (vm *VM) LastAccepted(context.Context) (ids.ID, error) {
|
|
return vm.genesis.ID(), nil
|
|
}
|
|
|
|
// GetBlockIDAtHeight implements the chain.ChainVM interface. Genesis (height 0)
|
|
// is the only block; every other height is absent.
|
|
func (vm *VM) GetBlockIDAtHeight(ctx context.Context, height uint64) (ids.ID, error) {
|
|
if height != 0 {
|
|
return ids.Empty, database.ErrNotFound
|
|
}
|
|
return vm.genesis.ID(), nil
|
|
}
|
|
|
|
// maxRequestBytes bounds what a caller may POST. parseQuery's length check runs
|
|
// after decoding, which is too late to stop a body that never ends.
|
|
const maxRequestBytes = maxQueryLength + 1<<16
|
|
|
|
// queryHandler answers GraphQL over HTTP. POST with a JSON body is the one way
|
|
// in; a query in a URL is a second way to say the same thing.
|
|
type queryHandler struct {
|
|
vm *VM
|
|
}
|
|
|
|
func (h *queryHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
if r.Method != http.MethodPost {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
writeErr(w, "graphql: POST a JSON body")
|
|
return
|
|
}
|
|
|
|
var req GraphQLRequest
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxRequestBytes)).Decode(&req); err != nil {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
writeErr(w, err.Error())
|
|
return
|
|
}
|
|
|
|
// A GraphQL error is a 200 with an errors array; the request was understood.
|
|
json.NewEncoder(w).Encode(h.vm.queries.Execute(r.Context(), &req))
|
|
}
|
|
|
|
func writeErr(w http.ResponseWriter, msg string) {
|
|
json.NewEncoder(w).Encode(&GraphQLResponse{Errors: []GraphQLError{{Message: msg}}})
|
|
}
|
|
|
|
// authenticated admits a request carrying one of the configured API keys.
|
|
// Initialize has already refused an empty key, so an empty token matches
|
|
// nothing here.
|
|
func authenticated(next http.Handler, keys []string) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
token := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
|
|
|
|
var ok bool
|
|
for _, key := range keys {
|
|
if subtle.ConstantTimeCompare([]byte(token), []byte(key)) == 1 {
|
|
ok = true
|
|
}
|
|
}
|
|
if !ok {
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|