mirror of
https://github.com/luxfi/chains.git
synced 2026-09-24 00:26:00 +00:00
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]>
706 lines
21 KiB
Go
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)
|
|
})
|
|
}
|