mirror of
https://github.com/luxfi/precompile.git
synced 2026-09-24 00:26:21 +00:00
graph 58.0 -> 99.1, graphvm 0 -> 100, ai 90.0 -> 96.2, inference 87.3 -> 98.0, modelregistry 70.2 -> 98.9, aivmbridge 93.1 -> 99.8, anchor 98.1 -> 99.1, fhe 33.0 -> 80.1. graph charged one flat fee for any amount of work. The sharpest form was not response size but TargetChains: decoded from caller JSON with no cap, one GraphQL query per entry, so seven bytes of calldata bought an unbounded fan-out. Response size is only known after the work, so the fee now reserves the worst case up front and refunds down -- there is no deduction that can come up short, and a failed run consumes the reservation rather than everything supplied, so malformed input is not a cheap probe. ai halted the chain from eight bytes. Two handlers bounded their length prefixes in uint32, so near 2^32 the sum wrapped, the check passed and the slice panicked. Reproduced at both 96 and 8 bytes. The correct int-based parser already existed in the same package; the handlers just did not call it, and the existing test only probed 0xFFFF, which never wraps. fhe never enforced readOnly. It was threaded through Run and all 42 handlers and read by none, so every op but decrypt and sealOutput wrote ciphertext, metadata and ownership to the trie under STATICCALL. Decided once at dispatch; an unknown selector counts as a writer. fhe also allocated before it checked: five bytes of calldata asked for 34 GB behind a flat 50,000 gas fee. div and rem were registered and priced at 655M gas with no case in the switch, so both fell through and returned the zero handle with a nil error -- success for a guaranteed no-op, with the real implementations written and never wired. select decoded its control as a bare Ciphertext when every ebool the precompile stores is a BitCiphertext, so it could not have worked for any input; it surfaced only once failures stopped being reported as success. graph decodeArgs accepted truncated calldata as a shorter argument list, so trimming bytes changed which variables a query expanded with. graphvm chose resolvers by map iteration order, which is consensus-visible. Deleted a dead second gas table that disagreed with the real one, an address constant appearing only at its own declaration, and ten other symbols with no reader.
506 lines
18 KiB
Go
506 lines
18 KiB
Go
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package graph
|
|
|
|
import (
|
|
"context"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// GChainClient interface for communication with G-Chain
|
|
type GChainClient interface {
|
|
// Query executes a GraphQL query against G-Chain
|
|
Query(ctx context.Context, query string, variables map[string]any) ([]byte, error)
|
|
|
|
// QueryChain executes a query against a specific chain
|
|
QueryChain(ctx context.Context, chainID uint64, query string, variables map[string]any) ([]byte, error)
|
|
}
|
|
|
|
// Method selectors.
|
|
const (
|
|
selectorQuery uint32 = 0x01 // query(bytes)
|
|
selectorPredefined uint32 = 0x02 // queryPredefined(uint16, bytes[])
|
|
selectorStats uint32 = 0x03 // getStats()
|
|
)
|
|
|
|
// complexQueryLength is the query length above which the complex tier applies.
|
|
const complexQueryLength = 500
|
|
|
|
// GraphQLPrecompile implements the G-Chain GraphQL query interface
|
|
// This precompile enables any EVM contract to execute GraphQL queries
|
|
// against the unified G-Chain query layer.
|
|
//
|
|
// It is a pure read: it touches no EVM state and does not consult the caller, which is why
|
|
// neither appears in the signatures below. There is nothing here for a readOnly context to
|
|
// forbid and nothing for an access check to gate.
|
|
type GraphQLPrecompile struct {
|
|
// client is the connection to G-Chain. It is set ONCE during VM init
|
|
// (SetGraphVMClient), before the precompile is ever invoked in consensus, and is
|
|
// read-only thereafter — so the Run path holds no lock and shares no mutable state.
|
|
client GChainClient
|
|
|
|
// config holds runtime configuration, set once at Configure time and read-only
|
|
// during execution.
|
|
config *Config
|
|
}
|
|
|
|
// QueryStats is retained for response-shape compatibility only. A precompile may not
|
|
// accumulate process-global counters: they diverge across validators (and across a single
|
|
// node's restarts) and would leak nondeterministic data into consensus output. getStats
|
|
// therefore always returns the zero value — see runGetStats.
|
|
type QueryStats struct {
|
|
TotalQueries uint64
|
|
CacheHits uint64
|
|
CacheMisses uint64
|
|
TotalGasUsed uint64
|
|
AvgResponseTime time.Duration
|
|
}
|
|
|
|
// Config holds precompile configuration
|
|
type Config struct {
|
|
// MaxCacheSize is the maximum number of cache entries
|
|
MaxCacheSize int
|
|
|
|
// DefaultCacheTTL is the default cache time-to-live
|
|
DefaultCacheTTL time.Duration
|
|
|
|
// QueryTimeout is the maximum query execution time
|
|
QueryTimeout time.Duration
|
|
|
|
// GChainEndpoint is the G-Chain GraphQL endpoint
|
|
GChainEndpoint string
|
|
}
|
|
|
|
// NewGraphQLPrecompile creates a new GraphQL precompile instance
|
|
func NewGraphQLPrecompile(client GChainClient) *GraphQLPrecompile {
|
|
return &GraphQLPrecompile{
|
|
client: client,
|
|
config: &Config{
|
|
MaxCacheSize: 1000,
|
|
DefaultCacheTTL: 10 * time.Second,
|
|
QueryTimeout: 5 * time.Second,
|
|
GChainEndpoint: "http://localhost:9650/ext/bc/G/graphql",
|
|
},
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// Gas
|
|
// =========================================================================
|
|
|
|
// maxResponseGas is the price of the largest response the precompile may return. It is the
|
|
// allowance reserved against a response that does not exist yet; see gasForInput.
|
|
const maxResponseGas = uint64(MaxResponseSize) * GasPerByte
|
|
|
|
// requestGas prices everything the caller controls that is knowable BEFORE any work
|
|
// happens: the query bytes, the variable bytes, and the per-chain fan-out. Bytes cost
|
|
// GasPerByte wherever they appear, so there is one rule rather than one per field. It reads
|
|
// no state, no clock and no client, so every validator computes the same figure.
|
|
func requestGas(req QueryRequest) uint64 {
|
|
cost := GasQueryBase
|
|
if len(req.Query) > complexQueryLength {
|
|
cost += GasQueryComplex
|
|
} else {
|
|
cost += GasQuerySimple
|
|
}
|
|
// Each additional target chain is another round trip through the client; the fan-out
|
|
// is capped by validateQuery, which is what keeps this product bounded.
|
|
if len(req.TargetChains) > 1 {
|
|
cost += GasQueryCrossChain * uint64(len(req.TargetChains))
|
|
}
|
|
cost += uint64(len(req.Query)+len(req.Variables)) * GasPerByte
|
|
return cost
|
|
}
|
|
|
|
// gasForInput is the gas reserved before the precompile does anything, and the figure
|
|
// RequiredGas reports.
|
|
//
|
|
// Response size is only known once the work is done, so charging for it afterwards would
|
|
// mean the node had already performed work it might not be paid for. Instead the caller is
|
|
// charged the worst case — requestGas plus the largest response the precompile is allowed
|
|
// to return — before execution starts, and refunded down to the actual figure afterwards.
|
|
// A query therefore never runs unless its caller has already paid for the largest result it
|
|
// could produce, so there is no deduction that can come up short and no path on which work
|
|
// is performed for free. The refund is a pure function of a deterministic result.
|
|
//
|
|
// gasForInput reads only the calldata: no state, no client, no clock.
|
|
func gasForInput(input []byte) uint64 {
|
|
if len(input) < 4 {
|
|
return GasQueryBase
|
|
}
|
|
body := input[4:]
|
|
// Decoding the body is itself work proportional to its length, so it is priced even
|
|
// when it fails and nothing further is attempted.
|
|
decodeGas := GasQueryBase + uint64(len(body))*GasPerByte
|
|
|
|
switch binary.BigEndian.Uint32(input[:4]) {
|
|
case selectorQuery:
|
|
req, err := decodeQuery(body)
|
|
if err != nil {
|
|
return decodeGas
|
|
}
|
|
return requestGas(req) + maxResponseGas
|
|
case selectorPredefined:
|
|
req, err := decodePredefined(body)
|
|
if err != nil {
|
|
return decodeGas
|
|
}
|
|
return requestGas(req) + maxResponseGas
|
|
case selectorStats:
|
|
return GasQueryBase
|
|
default:
|
|
return decodeGas
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// Core Query Methods
|
|
// =========================================================================
|
|
|
|
// Query executes a GraphQL query and returns the result. It is the main entry point for
|
|
// EVM contracts and is a DETERMINISTIC, STATELESS function of (input, local G-Chain DB):
|
|
// - no time.Now()/wall-clock (it forked on per-validator clock skew),
|
|
// - no process-global cache (a warm-vs-cold node returned stale-vs-fresh data and
|
|
// different gas — both consensus-visible),
|
|
// - no process-global stats counters (cross-tx mutable state, returned on-chain),
|
|
// - no goroutines (scheduler order is nondeterministic),
|
|
// - a nil client returns a typed error instead of panicking the dispatch path.
|
|
//
|
|
// Every validator therefore computes the same bytes for the same block state.
|
|
//
|
|
// gasLimit is the caller's budget. On the precompile dispatch path it is the reservation
|
|
// already taken by RequiredGas; a direct caller of this method supplies its own.
|
|
func (p *GraphQLPrecompile) Query(req QueryRequest, gasLimit uint64) (QueryResponse, error) {
|
|
// Validate query
|
|
if err := p.validateQuery(req); err != nil {
|
|
return QueryResponse{}, err
|
|
}
|
|
|
|
// Fail CLOSED if the client was never wired (the default instance holds a nil
|
|
// client until SetGraphVMClient runs at VM init). A nil-interface call below would
|
|
// panic, and a panic in the precompile dispatch path is unrecovered — it halts every
|
|
// validator that processed the tx. Return a typed error instead.
|
|
if p.client == nil {
|
|
return QueryResponse{}, ErrClientNotInitialized
|
|
}
|
|
|
|
// Gas cost is a pure function of the query (no cache-hit discount), so it is
|
|
// identical on every node.
|
|
gasCost := requestGas(req)
|
|
if gasCost > gasLimit {
|
|
return QueryResponse{}, ErrGasExceeded
|
|
}
|
|
|
|
var variables map[string]any
|
|
if len(req.Variables) > 0 {
|
|
if err := json.Unmarshal(req.Variables, &variables); err != nil {
|
|
return QueryResponse{}, ErrInvalidQuery
|
|
}
|
|
}
|
|
|
|
// No wall-clock deadline: a context timeout fires nondeterministically (a slow node
|
|
// times out while a fast one succeeds) and forks the chain. The query is a bounded
|
|
// local-DB read, already gated by gas.
|
|
ctx := context.Background()
|
|
|
|
var result []byte
|
|
var err error
|
|
switch {
|
|
case len(req.TargetChains) == 0:
|
|
// Query all chains via the G-Chain unified layer.
|
|
result, err = p.client.Query(ctx, req.Query, variables)
|
|
case len(req.TargetChains) == 1:
|
|
// Query a specific chain.
|
|
result, err = p.client.QueryChain(ctx, req.TargetChains[0], req.Query, variables)
|
|
default:
|
|
// Multi-chain query (sequential, deterministic).
|
|
result, err = p.executeMultiChainQuery(ctx, req, variables)
|
|
}
|
|
|
|
if err != nil {
|
|
return QueryResponse{
|
|
Errors: []QueryError{{Message: err.Error()}},
|
|
GasUsed: gasCost,
|
|
}, nil
|
|
}
|
|
|
|
// Validate response size. This bound is what makes the upfront reservation total:
|
|
// the response term can never exceed the allowance already taken.
|
|
if len(result) > MaxResponseSize {
|
|
return QueryResponse{}, ErrQueryTooLarge
|
|
}
|
|
|
|
return QueryResponse{
|
|
Data: result,
|
|
GasUsed: gasCost + uint64(len(result))*GasPerByte,
|
|
}, nil
|
|
}
|
|
|
|
// QueryPredefined executes a pre-defined query template
|
|
// This provides lower gas costs for common queries
|
|
func (p *GraphQLPrecompile) QueryPredefined(queryID QueryID, args [][]byte, gasLimit uint64) (QueryResponse, error) {
|
|
req, err := expandPredefined(queryID, args)
|
|
if err != nil {
|
|
return QueryResponse{}, err
|
|
}
|
|
return p.Query(req, gasLimit)
|
|
}
|
|
|
|
// =========================================================================
|
|
// Decoding
|
|
// =========================================================================
|
|
|
|
// decodeQuery decodes selector-0x01 calldata. Pricing and execution share it, so the gas
|
|
// reserved is always computed from the same request that is subsequently run.
|
|
func decodeQuery(body []byte) (QueryRequest, error) {
|
|
var req QueryRequest
|
|
if err := json.Unmarshal(body, &req); err != nil {
|
|
return QueryRequest{}, ErrInvalidQuery
|
|
}
|
|
return req, nil
|
|
}
|
|
|
|
// decodePredefined decodes selector-0x02 calldata into the request its template expands to.
|
|
// Pricing and execution share it for the same reason as decodeQuery.
|
|
func decodePredefined(body []byte) (QueryRequest, error) {
|
|
if len(body) < 2 {
|
|
return QueryRequest{}, ErrInvalidQuery
|
|
}
|
|
args, err := decodeArgs(body[2:])
|
|
if err != nil {
|
|
return QueryRequest{}, err
|
|
}
|
|
return expandPredefined(QueryID(binary.BigEndian.Uint16(body[:2])), args)
|
|
}
|
|
|
|
// decodeArgs parses length-prefixed byte arrays. A truncated tail is REFUSED rather than
|
|
// silently accepted as a shorter argument list: a caller must not be able to change which
|
|
// arguments a query is expanded with by cutting its calldata short.
|
|
func decodeArgs(body []byte) ([][]byte, error) {
|
|
var args [][]byte
|
|
for offset := 0; offset < len(body); {
|
|
if offset+4 > len(body) {
|
|
return nil, ErrInvalidQuery
|
|
}
|
|
argLen := int(binary.BigEndian.Uint32(body[offset : offset+4]))
|
|
offset += 4
|
|
if argLen < 0 || offset+argLen > len(body) {
|
|
return nil, ErrInvalidQuery
|
|
}
|
|
args = append(args, body[offset:offset+argLen])
|
|
offset += argLen
|
|
}
|
|
return args, nil
|
|
}
|
|
|
|
// argNames binds positional arguments to template variable names.
|
|
var argNames = []string{"address", "id", "first", "orderBy", "owner"}
|
|
|
|
// expandPredefined turns a template ID plus positional arguments into the request that will
|
|
// actually be executed. It is the single expander: gas is computed from its output and so
|
|
// is the query that runs, so the two cannot disagree.
|
|
func expandPredefined(queryID QueryID, args [][]byte) (QueryRequest, error) {
|
|
template, ok := PredefinedQueries[queryID]
|
|
if !ok {
|
|
return QueryRequest{}, ErrInvalidQuery
|
|
}
|
|
if len(args) > template.MaxArgs {
|
|
return QueryRequest{}, ErrInvalidQuery
|
|
}
|
|
|
|
variables := make(map[string]any, len(args))
|
|
for i, arg := range args {
|
|
if i < len(argNames) {
|
|
variables[argNames[i]] = string(arg)
|
|
}
|
|
}
|
|
// json.Marshal sorts map keys, so the encoding is byte-identical on every validator.
|
|
varsJSON, err := json.Marshal(variables)
|
|
if err != nil {
|
|
return QueryRequest{}, ErrInvalidQuery
|
|
}
|
|
|
|
return QueryRequest{Query: template.Query, Variables: varsJSON}, nil
|
|
}
|
|
|
|
// =========================================================================
|
|
// Helper Methods
|
|
// =========================================================================
|
|
|
|
// validateQuery validates the query request
|
|
func (p *GraphQLPrecompile) validateQuery(req QueryRequest) error {
|
|
if len(req.Query) == 0 {
|
|
return ErrInvalidQuery
|
|
}
|
|
if len(req.Query) > MaxQuerySize {
|
|
return ErrQueryTooLarge
|
|
}
|
|
// The fan-out drives one client round trip per entry and one GasQueryCrossChain term
|
|
// per entry. Capping the slice bounds both the loop and the arithmetic.
|
|
if len(req.TargetChains) > MaxTargetChains {
|
|
return ErrQueryTooLarge
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// executeMultiChainQuery executes a query across multiple chains SEQUENTIALLY. The
|
|
// consensus path must be free of goroutines: scheduler order is nondeterministic, and the
|
|
// previous concurrent version let whichever goroutine raced first win the "first error"
|
|
// slot — so different validators could surface different errors (or partial result sets)
|
|
// and fork. Iterating TargetChains in order makes both the result map and the first error
|
|
// deterministic; json.Marshal then sorts the integer keys, so the bytes are identical
|
|
// everywhere.
|
|
func (p *GraphQLPrecompile) executeMultiChainQuery(
|
|
ctx context.Context,
|
|
req QueryRequest,
|
|
variables map[string]any,
|
|
) ([]byte, error) {
|
|
results := make(map[uint64]json.RawMessage)
|
|
var firstErr error
|
|
|
|
for _, chainID := range req.TargetChains {
|
|
result, err := p.client.QueryChain(ctx, chainID, req.Query, variables)
|
|
if err != nil {
|
|
if firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
continue
|
|
}
|
|
results[chainID] = json.RawMessage(result)
|
|
}
|
|
|
|
if firstErr != nil && len(results) == 0 {
|
|
return nil, firstErr
|
|
}
|
|
|
|
combined := map[string]any{
|
|
"data": results,
|
|
}
|
|
return json.Marshal(combined)
|
|
}
|
|
|
|
// =========================================================================
|
|
// View Methods
|
|
// =========================================================================
|
|
|
|
// GetStats returns query statistics. Precompiles cannot keep process-global counters
|
|
// (they diverge across validators), so this is always the deterministic zero value.
|
|
func (p *GraphQLPrecompile) GetStats() *QueryStats {
|
|
return &QueryStats{}
|
|
}
|
|
|
|
// GetConfig returns the current (immutable) configuration.
|
|
func (p *GraphQLPrecompile) GetConfig() *Config {
|
|
return p.config
|
|
}
|
|
|
|
// =========================================================================
|
|
// EVM Precompile Interface
|
|
// =========================================================================
|
|
|
|
// RequiredGas returns the gas that must be reserved before Run is allowed to execute.
|
|
func (p *GraphQLPrecompile) RequiredGas(input []byte) uint64 {
|
|
return gasForInput(input)
|
|
}
|
|
|
|
// Run executes the precompile and reports the gas actually consumed, which is never more
|
|
// than the RequiredGas reserved for the same input.
|
|
func (p *GraphQLPrecompile) Run(input []byte) ([]byte, uint64, error) {
|
|
if len(input) < 4 {
|
|
return nil, GasQueryBase, ErrInvalidQuery
|
|
}
|
|
|
|
body := input[4:]
|
|
decodeGas := GasQueryBase + uint64(len(body))*GasPerByte
|
|
|
|
switch binary.BigEndian.Uint32(input[:4]) {
|
|
case selectorQuery:
|
|
req, err := decodeQuery(body)
|
|
if err != nil {
|
|
return nil, decodeGas, err
|
|
}
|
|
return p.run(req)
|
|
case selectorPredefined:
|
|
req, err := decodePredefined(body)
|
|
if err != nil {
|
|
return nil, decodeGas, err
|
|
}
|
|
return p.run(req)
|
|
case selectorStats:
|
|
out, err := p.runGetStats()
|
|
return out, GasQueryBase, err
|
|
default:
|
|
return nil, decodeGas, fmt.Errorf("unknown method: %x", input[:4])
|
|
}
|
|
}
|
|
|
|
// run executes an already-decoded request under the reservation RequiredGas took for it.
|
|
//
|
|
// A response carrying GraphQL errors is surfaced as a Go error so the EVM call reverts:
|
|
// returning empty data with no error would leave a caller unable to tell a failed query
|
|
// from a genuinely empty result.
|
|
func (p *GraphQLPrecompile) run(req QueryRequest) ([]byte, uint64, error) {
|
|
reserved := requestGas(req) + maxResponseGas
|
|
resp, err := p.Query(req, reserved)
|
|
if err != nil {
|
|
return nil, reserved, err
|
|
}
|
|
if len(resp.Errors) > 0 {
|
|
return nil, resp.GasUsed, fmt.Errorf("graphql: %s", resp.Errors[0].Message)
|
|
}
|
|
return resp.Data, resp.GasUsed, nil
|
|
}
|
|
|
|
// runGetStats handles the getStats method call. It returns the deterministic zero stats
|
|
// (telemetry counters are not consensus state).
|
|
func (p *GraphQLPrecompile) runGetStats() ([]byte, error) {
|
|
return json.Marshal(p.GetStats())
|
|
}
|
|
|
|
// =========================================================================
|
|
// Solidity Interface (for documentation)
|
|
// =========================================================================
|
|
|
|
// Solidity interface:
|
|
//
|
|
// interface IGraphQL {
|
|
// struct QueryRequest {
|
|
// string query;
|
|
// bytes variables;
|
|
// string operationName;
|
|
// uint64[] targetChains;
|
|
// }
|
|
//
|
|
// struct QueryResponse {
|
|
// bytes data;
|
|
// QueryError[] errors;
|
|
// uint64 gasUsed;
|
|
// }
|
|
//
|
|
// struct QueryError {
|
|
// string message;
|
|
// string[] path;
|
|
// }
|
|
//
|
|
// function query(QueryRequest calldata req) external returns (QueryResponse memory);
|
|
// function queryPredefined(uint16 queryId, bytes[] calldata args) external returns (bytes memory);
|
|
// function getStats() external view returns (uint64 totalQueries, uint64 cacheHits, uint64 cacheMisses);
|
|
//
|
|
// // Pre-defined query IDs
|
|
// uint16 constant QUERY_CHAIN_INFO = 0x0001;
|
|
// uint16 constant QUERY_BALANCE = 0x0101;
|
|
// uint16 constant QUERY_FACTORY = 0x0201;
|
|
// uint16 constant QUERY_BUNDLE = 0x0202;
|
|
// uint16 constant QUERY_TOKEN = 0x0301;
|
|
// uint16 constant QUERY_TOKENS = 0x0302;
|
|
// uint16 constant QUERY_POOL = 0x0401;
|
|
// uint16 constant QUERY_POOLS = 0x0402;
|
|
// uint16 constant QUERY_POSITIONS = 0x0601;
|
|
// uint16 constant QUERY_SWAPS = 0x0701;
|
|
// uint16 constant QUERY_ALL_CHAINS_TVL = 0x0F01;
|
|
// }
|