Files
chains/graphvm/vm.go
T
zeekayandHanzo Dev ea1b0efe60 graphvm: say that this chain builds no blocks, and bridgevm: one status type
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. That was written down in a
comment on its state and nowhere the code could be read from: BuildBlock
answered a generic "not implemented", which reads as an omission, and
WaitForEvent's comment said it would one day wait for queued schema updates,
which invites exactly the latch the other chains just got. Both now name the
reason. A block here would carry a query result, which a peer recomputes from
state, or a schema, which is local configuration; neither is something the
network votes on. A test pins it, so the difference between a chain with nothing
to say and a chain with no way to say it stays visible.

BridgeRequest.Status was a string whose valid values lived in a comment beside
it, while the same lifecycle already had a type and eight constants on
BridgeRequestRecord. One concept, two representations, and the one the watcher
wrote to was the untyped one. It now uses the type.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-25 13:09:57 -07:00

706 lines
21 KiB
Go

// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package gvm 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 with auto-indexing and cross-chain query resolution.
package graphvm
import (
"context"
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/luxfi/database"
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/node/vms/types/fee"
"github.com/luxfi/runtime"
vmcore "github.com/luxfi/vm"
"github.com/luxfi/vm/chain"
"github.com/luxfi/warp"
nodeversion "github.com/luxfi/node/version"
)
var (
_ chain.ChainVM = (*VM)(nil)
Version = &nodeversion.Semantic{
Major: 1,
Minor: 0,
Patch: 0,
}
errNotImplemented = errors.New("not implemented")
// errReadOnlyChain is what BuildBlock answers. 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. That is the design, not a gap: a block
// here would commit a query result, which is derived from state a peer can
// recompute, or a schema, which is local configuration. Neither is
// something the network has to vote on.
errReadOnlyChain = errors.New("graphvm: this chain indexes other chains and builds no blocks")
)
// GConfig contains VM configuration
type GConfig struct {
// DGraph configuration
DgraphEndpoint string `json:"dgraphEndpoint"`
SchemaVersion string `json:"schemaVersion"`
EnableFederation bool `json:"enableFederation"`
// Query configuration
MaxQueryDepth int `json:"maxQueryDepth"`
QueryTimeoutMs int `json:"queryTimeoutMs"`
MaxResultSize int `json:"maxResultSize"`
// Index configuration
AutoIndex bool `json:"autoIndex"`
IndexBatchSize int `json:"indexBatchSize"`
// Authentication configuration
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
// State. The G-Chain is read-only and never advances past genesis, so
// genesisBlock is permanently the last-accepted block. lastAcceptedID is
// what LastAccepted() returns and what the engine resolves via GetBlock at
// initialization; preferredID tracks SetPreference separately.
genesisBlock *Block
lastAcceptedID ids.ID
preferredID ids.ID
// Graph-specific fields
schemas map[string]*GraphSchema
queries map[ids.ID]*Query
subscriptions map[ids.ID]*Subscription
dataIndexes map[string]*DataIndex
chainSources map[ids.ID]*ChainDataSource
// Synchronization
schemaMu sync.RWMutex
queryMu sync.RWMutex
// G-Chain is read-only; the policy refuses all user-tx at the boundary.
// See feegate.go.
feePolicy fee.Policy
}
// GraphSchema represents a GraphQL schema definition
type GraphSchema struct {
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
Schema string `json:"schema"`
Types []string `json:"types"`
Directives []string `json:"directives"`
CreatedAt int64 `json:"createdAt"`
UpdatedAt int64 `json:"updatedAt"`
}
// Query represents a GraphQL query
type Query struct {
ID ids.ID `json:"id"`
QueryText string `json:"queryText"`
Variables []byte `json:"variables"`
ChainScope []ids.ID `json:"chainScope"`
Result []byte `json:"result,omitempty"`
Status QueryStatus `json:"status"`
SubmittedAt int64 `json:"submittedAt"`
CompletedAt int64 `json:"completedAt,omitempty"`
}
// Subscription represents a GraphQL subscription
type Subscription struct {
ID ids.ID `json:"id"`
QueryText string `json:"queryText"`
ChainScope []ids.ID `json:"chainScope"`
Active bool `json:"active"`
CreatedAt int64 `json:"createdAt"`
}
// DataIndex represents an index for optimized queries
type DataIndex struct {
ID string `json:"id"`
ChainID ids.ID `json:"chainId"`
IndexType string `json:"indexType"`
Fields []string `json:"fields"`
Status string `json:"status"`
}
// ChainDataSource represents a connected chain data source
type ChainDataSource struct {
ChainID ids.ID `json:"chainId"`
ChainName string `json:"chainName"`
Connected bool `json:"connected"`
LastSync int64 `json:"lastSync"`
BlockHeight uint64 `json:"blockHeight"`
}
// QueryStatus represents the status of a query
type QueryStatus uint8
const (
QueryPending QueryStatus = iota
QueryProcessing
QueryCompleted
QueryFailed
)
// 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
// Parse config
if len(vmInit.Config) > 0 {
if err := json.Unmarshal(vmInit.Config, &vm.config); err != nil {
return fmt.Errorf("failed to parse config: %w", err)
}
}
// Initialize state management
vm.schemas = make(map[string]*GraphSchema)
vm.queries = make(map[ids.ID]*Query)
vm.subscriptions = make(map[ids.ID]*Subscription)
vm.dataIndexes = make(map[string]*DataIndex)
vm.chainSources = make(map[ids.ID]*ChainDataSource)
// 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)
}
// Parse genesis if needed
if len(vmInit.Genesis) > 0 {
if err := vm.parseGenesis(vmInit.Genesis); err != nil {
return fmt.Errorf("failed to parse genesis: %w", err)
}
}
// Build the deterministic genesis block and pin it as the last-accepted
// block. The G-Chain is a read-only query/index VM that never advances
// past genesis, but the consensus engine still 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 on lux-mainnet.
genesisBlock, err := newGenesisBlock(vm, vmInit.Genesis)
if err != nil {
return fmt.Errorf("failed to build genesis block: %w", err)
}
vm.genesisBlock = genesisBlock
vm.lastAcceptedID = genesisBlock.ID()
vm.preferredID = genesisBlock.ID()
if logger, ok := vm.rt.Log.(log.Logger); ok {
logger.Info("initialized Graph VM",
log.Reflect("version", Version),
log.String("genesisBlockID", vm.lastAcceptedID.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/bc/<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) {
h := &apiHandler{vm: vm}
guard := func(next http.Handler) http.Handler {
if vm.config.RequireAuth {
return authMiddleware(next, vm.config.APIKeys)
}
return next
}
return map[string]http.Handler{
"/graphql": guard(http.HandlerFunc(h.handleGraphQL)),
"/schema": http.HandlerFunc(h.handleSchema), // Schema can be public
"/query": guard(http.HandlerFunc(h.handleQuery)),
"/index": http.HandlerFunc(h.handleIndex), // Index metadata can be public
}, nil
}
// NewHTTPHandler returns HTTP handlers for the VM
func (vm *VM) NewHTTPHandler(ctx context.Context) (http.Handler, error) {
return &apiHandler{vm: vm}, 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()
}
// HealthCheck implements the health.Checker interface
func (vm *VM) HealthCheck(context.Context) (chain.HealthResult, error) {
vm.schemaMu.RLock()
schemaCount := len(vm.schemas)
vm.schemaMu.RUnlock()
vm.queryMu.RLock()
queryCount := len(vm.queries)
subCount := len(vm.subscriptions)
vm.queryMu.RUnlock()
return chain.HealthResult{
Healthy: true,
Details: map[string]string{
"version": Version.String(),
"schemas": fmt.Sprintf("%d", schemaCount),
"queries": fmt.Sprintf("%d", queryCount),
"subscriptions": fmt.Sprintf("%d", subCount),
"indexes": fmt.Sprintf("%d", len(vm.dataIndexes)),
"chainSources": fmt.Sprintf("%d", len(vm.chainSources)),
"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 errNotImplemented
}
// 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
}
// 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. It decodes the canonical
// block encoding produced by Block.Bytes(), recomputing the content-addressed
// ID so a re-parsed block is byte- and ID-identical to the original.
func (vm *VM) ParseBlock(ctx context.Context, blockBytes []byte) (chain.Block, error) {
blk, err := parseBlock(vm, blockBytes)
if err != nil {
return nil, err
}
return blk, nil
}
// 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 errNotImplemented) 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 vm.genesisBlock != nil && blkID == vm.genesisBlock.ID() {
return vm.genesisBlock, nil
}
return nil, database.ErrNotFound
}
// SetPreference implements the chain.ChainVM interface
func (vm *VM) SetPreference(ctx context.Context, blkID ids.ID) error {
vm.preferredID = blkID
return nil
}
// LastAccepted implements the chain.ChainVM interface.
func (vm *VM) LastAccepted(context.Context) (ids.ID, error) {
return vm.lastAcceptedID, 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 && vm.genesisBlock != nil {
return vm.genesisBlock.ID(), nil
}
return ids.Empty, database.ErrNotFound
}
// parseGenesis parses the genesis data
func (vm *VM) parseGenesis(genesisBytes []byte) error {
type Genesis struct {
DefaultSchema string `json:"defaultSchema"`
ChainSources []string `json:"chainSources"`
DgraphEndpoint string `json:"dgraphEndpoint"`
SchemaVersion string `json:"schemaVersion"`
}
var genesis Genesis
if err := json.Unmarshal(genesisBytes, &genesis); err != nil {
return err
}
// Initialize default schema
if genesis.DefaultSchema != "" {
vm.schemas["default"] = &GraphSchema{
ID: "default",
Name: "Default Schema",
Version: genesis.SchemaVersion,
Schema: genesis.DefaultSchema,
CreatedAt: time.Now().Unix(),
}
}
return nil
}
// ExecuteQuery executes a GraphQL query against registered schemas and data sources
func (vm *VM) ExecuteQuery(query *Query) error {
vm.queryMu.Lock()
defer vm.queryMu.Unlock()
query.Status = QueryProcessing
query.SubmittedAt = time.Now().Unix()
vm.queries[query.ID] = query
// Parse and execute the GraphQL query
result, err := vm.executeGraphQLQuery(query.QueryText, query.Variables, query.ChainScope)
if err != nil {
query.Status = QueryFailed
query.Result = []byte(fmt.Sprintf(`{"errors":[{"message":%q}]}`, err.Error()))
return err
}
query.Status = QueryCompleted
query.CompletedAt = time.Now().Unix()
query.Result = result
return nil
}
// executeGraphQLQuery is the core GraphQL execution engine
func (vm *VM) executeGraphQLQuery(queryText string, variables []byte, chainScope []ids.ID) ([]byte, error) {
// Parse query to extract operation type and fields
op, fields, err := parseGraphQLQuery(queryText)
if err != nil {
return nil, fmt.Errorf("query parse error: %w", err)
}
// Only queries are supported for now (no mutations/subscriptions)
if op != "query" && op != "" {
return nil, fmt.Errorf("unsupported operation type: %s", op)
}
// Build response data by resolving each top-level field
data := make(map[string]interface{})
for _, field := range fields {
value, err := vm.resolveField(field, chainScope)
if err != nil {
// GraphQL returns partial results with errors
data[field] = nil
continue
}
data[field] = value
}
// Encode response as JSON
response := map[string]interface{}{
"data": data,
}
return json.Marshal(response)
}
// parseGraphQLQuery is a minimal GraphQL query parser
// Supports basic queries like: query { field1 field2 }
func parseGraphQLQuery(queryText string) (operation string, fields []string, err error) {
// Trim whitespace and normalize
queryText = strings.TrimSpace(queryText)
if queryText == "" {
return "", nil, errors.New("empty query")
}
// Check for operation type prefix
operation = "query" // default
if strings.HasPrefix(queryText, "query") {
queryText = strings.TrimPrefix(queryText, "query")
queryText = strings.TrimSpace(queryText)
} else if strings.HasPrefix(queryText, "mutation") {
return "mutation", nil, errors.New("mutations not supported")
} else if strings.HasPrefix(queryText, "subscription") {
return "subscription", nil, errors.New("subscriptions not supported")
}
// Skip optional operation name
if idx := strings.Index(queryText, "{"); idx > 0 {
queryText = queryText[idx:]
}
// Extract fields from within braces
if !strings.HasPrefix(queryText, "{") || !strings.HasSuffix(queryText, "}") {
return "", nil, errors.New("invalid query format: expected { fields }")
}
// Remove braces and extract field names
fieldStr := strings.TrimPrefix(queryText, "{")
fieldStr = strings.TrimSuffix(fieldStr, "}")
fieldStr = strings.TrimSpace(fieldStr)
// Split by whitespace or newlines to get field names
// Note: This is simplified and doesn't handle nested fields
fieldNames := strings.Fields(fieldStr)
for _, f := range fieldNames {
// Clean up field names (remove any sub-selections for now)
if idx := strings.Index(f, "{"); idx > 0 {
f = f[:idx]
}
if f != "" && f != "{" && f != "}" {
fields = append(fields, f)
}
}
return operation, fields, nil
}
// resolveField resolves a single GraphQL field against the available data sources
func (vm *VM) resolveField(fieldName string, chainScope []ids.ID) (interface{}, error) {
vm.schemaMu.RLock()
defer vm.schemaMu.RUnlock()
// Built-in introspection fields
switch fieldName {
case "__schema":
return vm.introspectSchema(), nil
case "__typename":
return "Query", nil
case "schemas":
// Return all registered schemas
schemas := make([]map[string]string, 0, len(vm.schemas))
for _, s := range vm.schemas {
schemas = append(schemas, map[string]string{
"id": s.ID,
"name": s.Name,
"version": s.Version,
})
}
return schemas, nil
case "chainSources":
// Return connected chain sources
sources := make([]map[string]interface{}, 0, len(vm.chainSources))
for _, cs := range vm.chainSources {
sources = append(sources, map[string]interface{}{
"chainId": cs.ChainID.String(),
"chainName": cs.ChainName,
"connected": cs.Connected,
"blockHeight": cs.BlockHeight,
})
}
return sources, nil
case "indexes":
// Return data indexes
indexes := make([]map[string]interface{}, 0, len(vm.dataIndexes))
for _, idx := range vm.dataIndexes {
indexes = append(indexes, map[string]interface{}{
"id": idx.ID,
"indexType": idx.IndexType,
"status": idx.Status,
"fields": idx.Fields,
})
}
return indexes, nil
}
return nil, fmt.Errorf("unknown field: %s", fieldName)
}
// introspectSchema returns GraphQL schema introspection data
func (vm *VM) introspectSchema() map[string]interface{} {
return map[string]interface{}{
"queryType": map[string]string{
"name": "Query",
},
"types": []map[string]string{
{"name": "Query"},
{"name": "Schema"},
{"name": "ChainSource"},
{"name": "Index"},
},
}
}
// RegisterSchema registers a new GraphQL schema
func (vm *VM) RegisterSchema(schema *GraphSchema) error {
vm.schemaMu.Lock()
defer vm.schemaMu.Unlock()
schema.CreatedAt = time.Now().Unix()
schema.UpdatedAt = schema.CreatedAt
vm.schemas[schema.ID] = schema
return nil
}
// ConnectChainSource connects a chain as a data source
func (vm *VM) ConnectChainSource(chainID ids.ID, chainName string) error {
vm.chainSources[chainID] = &ChainDataSource{
ChainID: chainID,
ChainName: chainName,
Connected: true,
LastSync: time.Now().Unix(),
}
return nil
}
// API handler for Graph-specific endpoints
type apiHandler struct {
vm *VM
}
func (h *apiHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/graphql":
h.handleGraphQL(w, r)
case "/schema":
h.handleSchema(w, r)
case "/query":
h.handleQuery(w, r)
case "/index":
h.handleIndex(w, r)
default:
http.Error(w, "not found", http.StatusNotFound)
}
}
func (h *apiHandler) handleGraphQL(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"data": nil,
"errors": []string{"GraphQL endpoint ready"},
})
}
func (h *apiHandler) handleSchema(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
h.vm.schemaMu.RLock()
defer h.vm.schemaMu.RUnlock()
json.NewEncoder(w).Encode(h.vm.schemas)
}
func (h *apiHandler) handleQuery(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "query endpoint ready",
})
}
func (h *apiHandler) handleIndex(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(h.vm.dataIndexes)
}
// authMiddleware validates API key from Authorization header
func authMiddleware(next http.Handler, validKeys []string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Extract token from Authorization header
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "Unauthorized: missing Authorization header", http.StatusUnauthorized)
return
}
// Support both "Bearer <token>" and just "<token>"
token := strings.TrimPrefix(authHeader, "Bearer ")
token = strings.TrimSpace(token)
// Validate token against configured API keys (constant-time comparison)
var valid bool
for _, validKey := range validKeys {
if subtle.ConstantTimeCompare([]byte(token), []byte(validKey)) == 1 {
valid = true
break
}
}
if !valid {
http.Error(w, "Unauthorized: invalid API key", http.StatusUnauthorized)
return
}
// Token is valid, proceed
next.ServeHTTP(w, r)
})
}