Files
zeekay d1de30ce04 graphvm: a chain that builds no block admits no block
G-Chain declines every BuildBlock, and then ParseBlock accepted 56 bytes
of well-formed ZAP naming any height and any parent, Verify passed it,
and Accept moved lastAccepted to it — after which GetBlock(LastAccepted())
answered not-found and the node could not boot. One block off the wire,
one permanently unstartable node. The refusal is now one predicate that
admission, assembly, preference and consensus all answer with.

The block carried four payload slices no code ever wrote, so Accept's and
Verify's loops over them were vacuous, including the unlocked read of
vm.queries. Deleted, with the four types. The VM also held a second
GraphQL parser and resolver table with no callers, behind two endpoints
that answered "GraphQL endpoint ready" to every query; the real executor
is now what /graphql serves, and the placeholders are gone.

In that executor:

  first: "-1" reached make([]*T, 0, -1) and panicked the process, and
  mints and burns had no upper bound at all. Forty near-identical
  resolvers are now two shapes, so the bound is a property of the shape.

  where: {token0: "0x1"} arrived as the string "{token0" — the argument
  list was split on commas — so every filtered query answered with the
  whole table and reported no error.

  { a b } was one field named "a b": the selection set was split on
  commas and newlines only.

  MaxResultSize was stored and never read, and QueryTimeoutMs bounded
  nothing because no resolver watched the deadline.

  sortTokens returned !cmp to reverse, reporting i<j and j<i for every
  tie, which left sort.Slice free to permute equal rows differently on
  identical data.

  RequireAuth with a configured "" admitted `Authorization: Bearer `,
  because ConstantTimeCompare reports equal for two empty slices. The
  chain refuses to boot on that config instead.

Coverage 31.8% -> 99.8%.
2026-08-28 14:37:46 -07:00

210 lines
7.8 KiB
Go

// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package graphvm
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"github.com/luxfi/database"
"github.com/luxfi/database/memdb"
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/runtime"
vmcore "github.com/luxfi/vm"
)
func TestFactoryBuildsAVM(t *testing.T) {
vm, err := (&Factory{}).New(log.NewNoOpLogger())
require.NoError(t, err)
require.IsType(t, &VM{}, vm)
require.Equal(t, ids.ID{'g', 'r', 'a', 'p', 'h', 'v', 'm'}, VMID)
}
func TestInitializeLogsThroughTheRuntimeLogger(t *testing.T) {
require.NoError(t, (&VM{}).Initialize(context.Background(), vmcore.Init{
Runtime: &runtime.Runtime{NetworkID: 1, Log: log.NewNoOpLogger()},
DB: memdb.New(),
}))
}
// An argument value is an object, a list, a quoted string or a bare token. It
// used to be "everything between two commas", which is why `where: {a: "b"}`
// arrived as the string "{a".
func TestArgumentValues(t *testing.T) {
for _, tt := range []struct {
name, in string
want map[string]interface{}
}{
{name: "empty", in: ``, want: map[string]interface{}{}},
{name: "bare", in: `a: 1`, want: map[string]interface{}{"a": "1"}},
{name: "quoted", in: `a: "x y"`, want: map[string]interface{}{"a": "x y"}},
{name: "single quoted", in: `a: 'x'`, want: map[string]interface{}{"a": "x"}},
{name: "escape", in: `a: "x\"y"`, want: map[string]interface{}{"a": `x"y`}},
{name: "unterminated quote", in: `a: "xy`, want: map[string]interface{}{"a": "xy"}},
{name: "two", in: `a: 1, b: 2`, want: map[string]interface{}{"a": "1", "b": "2"}},
{name: "spaces", in: ` a : 1 `, want: map[string]interface{}{"a": "1"}},
{name: "no value", in: `a:`, want: map[string]interface{}{"a": ""}},
{name: "bare token, no colon", in: `nonsense`, want: map[string]interface{}{}},
{name: "leading junk", in: `!! a: 1`, want: map[string]interface{}{"a": "1"}},
{
name: "object",
in: `where: {token0: "0xA", n: 2}, first: "5"`,
want: map[string]interface{}{
"where": map[string]interface{}{"token0": "0xA", "n": "2"},
"first": "5",
},
},
{
name: "nested object",
in: `where: {a: {b: "c"}}`,
want: map[string]interface{}{"where": map[string]interface{}{"a": map[string]interface{}{"b": "c"}}},
},
{
name: "list",
in: `ids: ["a", b, {c: "d"}]`,
want: map[string]interface{}{"ids": []interface{}{"a", "b", map[string]interface{}{"c": "d"}}},
},
{name: "empty list", in: `ids: [ , ]`, want: map[string]interface{}{"ids": []interface{}{}}},
{name: "unterminated object", in: `where: {a: "b"`, want: map[string]interface{}{"where": ""}},
{name: "unterminated list", in: `ids: [a`, want: map[string]interface{}{"ids": ""}},
} {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, parseArgs(tt.in))
})
}
}
// The tokenizer must refuse what it cannot read rather than inventing a field.
func TestMalformedSelectionSetsAreRefused(t *testing.T) {
e := NewQueryExecutor(memdb.New(), nil)
for _, tt := range []struct{ name, q string }{
// An unmatched brace inside an otherwise balanced query.
{name: "unmatched open brace", q: `{ a } b { c }`},
// A colon with no name after it is an alias of nothing.
{name: "alias with no name", q: `{ a { b: } }`},
{name: "alias with no name, top level", q: `{ b: }`},
// An argument list that never closes.
{name: "unclosed args", q: `{ get(key: "k" }`},
} {
t.Run(tt.name, func(t *testing.T) {
require.NotEmpty(t, e.Execute(context.Background(), &GraphQLRequest{Query: tt.q}).Errors)
})
}
// A byte that starts no name is stepped over, not turned into a field.
require.Len(t, query(t, e, `{ ,,, chainInfo ;;; }`), 1)
require.Len(t, query(t, e, `{ !! chainInfo }`), 1)
}
// A height may arrive as a JSON number through variables or as a Go int; both
// name the same block as the string form.
func TestBlockHeightAcceptsEveryNumberShape(t *testing.T) {
db := memdb.New()
defer db.Close()
seed(t, db, "block:height:100", map[string]interface{}{"hash": "0x1234"})
e := NewQueryExecutor(db, nil)
for _, height := range []interface{}{"100", float64(100), 100} {
resp := e.Execute(context.Background(), &GraphQLRequest{
Query: `{ block }`,
Variables: map[string]interface{}{"height": height},
})
require.Empty(t, resp.Errors, "height %#v", height)
blk := resp.Data.(map[string]interface{})["block"].(map[string]interface{})
require.Equal(t, "0x1234", blk["hash"], "height %#v", height)
}
}
// prefixDB fails reads under one prefix and serves the rest, so a resolver that
// reads two keys can be caught reporting success for the one that failed.
type prefixDB struct {
database.Database
fail string
err error
}
func (d *prefixDB) Get(k []byte) ([]byte, error) {
if len(k) >= len(d.fail) && string(k[:len(d.fail)]) == d.fail {
return nil, d.err
}
return d.Database.Get(k)
}
// A resolver stops when the caller has gone. Execute checks the deadline once
// per field, so these inner checks are reached only by driving the resolver
// itself — which is how a long scan gets interrupted mid-scan in production.
func TestResolversStopOnCancellation(t *testing.T) {
db := memdb.New()
defer db.Close()
for i := 0; i < 4; i++ {
seed(t, db, string(PrefixToken)+string(rune('a'+i)), &Token{ID: "t"})
seed(t, db, string(PrefixPool)+string(rune('a'+i)), &Pool{ID: "p"})
}
seed(t, db, PrefixPoolByToken+"0xtok", []string{"a", "b"})
e := NewQueryExecutor(db, nil)
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := e.resolvers["tokens"](ctx, db, map[string]interface{}{})
require.ErrorIs(t, err, context.Canceled)
_, err = e.resolvers["pools"](ctx, db, map[string]interface{}{
"where": map[string]interface{}{"token0": "0xtok"},
})
require.ErrorIs(t, err, context.Canceled)
_, err = e.resolvers["iterate"](ctx, db, map[string]interface{}{})
require.ErrorIs(t, err, context.Canceled)
_, err = scan[map[string]interface{}](ctx, db, "dex:", 10)
require.ErrorIs(t, err, context.Canceled)
}
// The pool-by-token index names addresses; a read of one of them that FAILS is
// an error, not a pool quietly missing from the answer.
func TestPoolIndexReportsAFailedMemberRead(t *testing.T) {
db := memdb.New()
defer db.Close()
seed(t, db, PrefixPoolByToken+"0xtok", []string{"a"})
seed(t, db, PrefixPool+"a", &Pool{ID: "a"})
e := NewQueryExecutor(db, nil)
require.Len(t, query(t, e, `{ pools(where: {token0: "0xtok"}) }`)["pools"], 1)
broken := &prefixDB{Database: db, fail: PrefixPool, err: context.DeadlineExceeded}
_, err := e.resolvers["pools"](context.Background(), broken, map[string]interface{}{
"where": map[string]interface{}{"token0": "0xtok"},
})
require.ErrorIs(t, err, context.DeadlineExceeded)
}
func TestTimeSeriesOrderNewestFirst(t *testing.T) {
db := memdb.New()
defer db.Close()
e := NewQueryExecutor(db, nil)
for i := 1; i <= 3; i++ {
n := int64(i)
seed(t, db, PrefixTokenDay+"t-"+string(rune('0'+i)), &TokenDayData{Token: "t", Date: n})
seed(t, db, PrefixTokenHour+"t-"+string(rune('0'+i)), &TokenHourData{Token: "t", PeriodStartUnix: n})
seed(t, db, PrefixPairDay+"p-"+string(rune('0'+i)), &PairDayData{PairAddress: "p", Date: n})
}
days := query(t, e, `{ tokenDayDatas(where: {token: "T"}) }`)["tokenDayDatas"].([]*TokenDayData)
require.Equal(t, []int64{3, 2, 1}, []int64{days[0].Date, days[1].Date, days[2].Date})
hours := query(t, e, `{ tokenHourDatas(where: {token: "t"}) }`)["tokenHourDatas"].([]*TokenHourData)
require.Equal(t, []int64{3, 2, 1}, []int64{hours[0].PeriodStartUnix, hours[1].PeriodStartUnix, hours[2].PeriodStartUnix})
pairs := query(t, e, `{ pairDayDatas(where: {pairAddress: "p"}) }`)["pairDayDatas"].([]*PairDayData)
require.Equal(t, []int64{3, 2, 1}, []int64{pairs[0].Date, pairs[1].Date, pairs[2].Date})
require.Len(t, query(t, e, `{ uniswapDayDatas }`)["uniswapDayDatas"], 0)
}