mirror of
https://github.com/luxfi/chains.git
synced 2026-07-27 03:39:41 +00:00
graphvm: resolve genesis as last-accepted block (fix G-Chain init)
The G-Chain VM declared chain.ChainVM but stubbed the block methods: GetBlock/ParseBlock returned errNotImplemented, LastAccepted returned the unset preferredID (ids.Empty), and Block.Status() returned choices.Status (not the uint8 the block.Block interface requires, so *Block never satisfied the interface). At boot the ZAP VM server (vm/rpc/vm_server_zap.go handleInitialize) calls LastAccepted() then GetBlock(lastAccepted); the GetBlock stub produced 'get last accepted block: not implemented', failing G-Chain initialization and the lux-mainnet node health check. The G-Chain is a read-only query/index VM that never advances past genesis, but consensus still requires a resolvable last-accepted block. Build a deterministic genesis block (height 0, content-addressed ID = SHA-256 of its canonical encoding) in Initialize, pin it as lastAccepted, and serve it from GetBlock/ParseBlock/GetBlockIDAtHeight. Fix Status() to return uint8 and add the var _ block.Block compile assertion so this can't regress. Tests reproduce the exact handleInitialize sequence plus byte/ID round-trip and cross-validator determinism.
This commit is contained in:
+85
-8
@@ -5,14 +5,23 @@ package graphvm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/consensus/core/choices"
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/consensus/engine/chain/block"
|
||||
"github.com/luxfi/crypto/hash"
|
||||
"github.com/luxfi/ids"
|
||||
)
|
||||
|
||||
// The consensus engine requires every ChainVM to resolve a real last-accepted
|
||||
// block at boot (LastAccepted -> GetBlock). This assertion guarantees *Block
|
||||
// satisfies block.Block; it is what catches the Status() signature mismatch
|
||||
// (the interface needs a concrete uint8, not the named choices.Status) at
|
||||
// compile time so GetBlock can actually return a *Block.
|
||||
var _ block.Block = (*Block)(nil)
|
||||
|
||||
var (
|
||||
errInvalidBlock = errors.New("invalid block")
|
||||
)
|
||||
@@ -126,6 +135,7 @@ func (b *Block) Accept(context.Context) error {
|
||||
}
|
||||
|
||||
// Update last accepted
|
||||
b.vm.lastAcceptedID = b.id
|
||||
b.vm.preferredID = b.id
|
||||
|
||||
return nil
|
||||
@@ -137,9 +147,12 @@ func (b *Block) Reject(context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Status implements the chain.Block interface
|
||||
func (b *Block) Status() choices.Status {
|
||||
return b.status
|
||||
// Status implements the block.Block interface. The interface requires a
|
||||
// concrete uint8; choices.Status is `type Status uint8`, so a method returning
|
||||
// the named type would NOT satisfy block.Block — which is why GetBlock could
|
||||
// never have returned a *Block before this fix.
|
||||
func (b *Block) Status() uint8 {
|
||||
return uint8(b.status)
|
||||
}
|
||||
|
||||
// Parent implements the chain.Block interface
|
||||
@@ -184,10 +197,74 @@ func (b *Block) Verify(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Bytes implements the chain.Block interface
|
||||
// Bytes implements the block.Block interface. It returns the deterministic
|
||||
// canonical encoding set at construction; the block ID is the SHA-256 of
|
||||
// exactly these bytes, so ParseBlock(b.Bytes()).ID() == b.ID().
|
||||
func (b *Block) Bytes() []byte {
|
||||
if b.bytes == nil {
|
||||
b.bytes = hash.ComputeHash256([]byte(b.id.String()))
|
||||
}
|
||||
return b.bytes
|
||||
}
|
||||
|
||||
// genesisTimestamp is the deterministic timestamp of the G-Chain genesis block.
|
||||
// Genesis is the root of trust (accepted by definition), so a fixed,
|
||||
// node-independent value is used — never time.Now(), which would make the
|
||||
// genesis block ID diverge across validators and break consensus agreement.
|
||||
var genesisTimestamp = time.Unix(0, 0).UTC()
|
||||
|
||||
// blockWire is the deterministic on-wire encoding of a G-Chain block. The block
|
||||
// ID is hash.ComputeHash256 of these bytes, so marshal/parse round-trips a
|
||||
// byte-identical ID across nodes and restarts.
|
||||
type blockWire struct {
|
||||
ParentID ids.ID `json:"parentID"`
|
||||
Height uint64 `json:"height"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Payload []byte `json:"payload,omitempty"`
|
||||
}
|
||||
|
||||
// newGenesisBlock builds the G-Chain genesis block (height 0) deterministically
|
||||
// from the genesis config bytes. The G-Chain is a read-only query/index chain —
|
||||
// it never builds blocks past genesis — so this is its permanent last-accepted
|
||||
// block, the one GetBlock(LastAccepted()) must return during Initialize.
|
||||
func newGenesisBlock(vm *VM, genesisBytes []byte) (*Block, error) {
|
||||
return newBlock(vm, ids.Empty, 0, genesisTimestamp, genesisBytes)
|
||||
}
|
||||
|
||||
// newBlock constructs a block, computes its canonical bytes and content-
|
||||
// addressed ID, and returns it ready to serve.
|
||||
func newBlock(vm *VM, parentID ids.ID, height uint64, timestamp time.Time, payload []byte) (*Block, error) {
|
||||
raw, err := json.Marshal(blockWire{
|
||||
ParentID: parentID,
|
||||
Height: height,
|
||||
Timestamp: timestamp.Unix(),
|
||||
Payload: payload,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Block{
|
||||
vm: vm,
|
||||
id: ids.ID(hash.ComputeHash256(raw)),
|
||||
parentID: parentID,
|
||||
height: height,
|
||||
timestamp: timestamp,
|
||||
status: choices.Accepted,
|
||||
bytes: raw,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseBlock decodes the canonical wire bytes produced by newBlock back into a
|
||||
// Block whose ID is recomputed from those exact bytes.
|
||||
func parseBlock(vm *VM, raw []byte) (*Block, error) {
|
||||
var wire blockWire
|
||||
if err := json.Unmarshal(raw, &wire); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Block{
|
||||
vm: vm,
|
||||
id: ids.ID(hash.ComputeHash256(raw)),
|
||||
parentID: wire.ParentID,
|
||||
height: wire.Height,
|
||||
timestamp: time.Unix(wire.Timestamp, 0).UTC(),
|
||||
status: choices.Accepted,
|
||||
bytes: raw,
|
||||
}, nil
|
||||
}
|
||||
|
||||
+46
-9
@@ -70,8 +70,13 @@ type VM struct {
|
||||
toEngine chan<- vmcore.Message
|
||||
appSender warp.Sender
|
||||
|
||||
// State
|
||||
preferredID ids.ID
|
||||
// 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
|
||||
@@ -187,9 +192,25 @@ func (vm *VM) Initialize(
|
||||
}
|
||||
}
|
||||
|
||||
// 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()),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -321,14 +342,26 @@ func (vm *VM) BuildBlock(ctx context.Context) (chain.Block, error) {
|
||||
return nil, errNotImplemented
|
||||
}
|
||||
|
||||
// ParseBlock implements the chain.ChainVM interface
|
||||
// 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) {
|
||||
return nil, errNotImplemented
|
||||
blk, err := parseBlock(vm, blockBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return blk, nil
|
||||
}
|
||||
|
||||
// GetBlock implements the chain.ChainVM interface
|
||||
// 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) {
|
||||
return nil, errNotImplemented
|
||||
if vm.genesisBlock != nil && blkID == vm.genesisBlock.ID() {
|
||||
return vm.genesisBlock, nil
|
||||
}
|
||||
return nil, database.ErrNotFound
|
||||
}
|
||||
|
||||
// SetPreference implements the chain.ChainVM interface
|
||||
@@ -337,13 +370,17 @@ func (vm *VM) SetPreference(ctx context.Context, blkID ids.ID) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// LastAccepted implements the chain.ChainVM interface
|
||||
// LastAccepted implements the chain.ChainVM interface.
|
||||
func (vm *VM) LastAccepted(context.Context) (ids.ID, error) {
|
||||
return vm.preferredID, nil
|
||||
return vm.lastAcceptedID, nil
|
||||
}
|
||||
|
||||
// GetBlockIDAtHeight implements the chain.ChainVM interface
|
||||
// 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
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// 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/runtime"
|
||||
vmcore "github.com/luxfi/vm"
|
||||
)
|
||||
|
||||
// initVM initializes a fresh G-Chain VM the way the node does, with the given
|
||||
// genesis bytes, and returns it ready to serve.
|
||||
func initVM(t *testing.T, genesis []byte) *VM {
|
||||
t.Helper()
|
||||
vm := &VM{}
|
||||
require.NoError(t, vm.Initialize(context.Background(), vmcore.Init{
|
||||
Runtime: &runtime.Runtime{NetworkID: 1},
|
||||
DB: memdb.New(),
|
||||
Genesis: genesis,
|
||||
}))
|
||||
return vm
|
||||
}
|
||||
|
||||
// TestInitializeResolvesLastAcceptedBlock reproduces the exact call sequence the
|
||||
// ZAP VM server performs inside handleInitialize (vm/rpc/vm_server_zap.go:262-270):
|
||||
// Initialize -> LastAccepted -> GetBlock(lastAccepted). Before the genesis-block
|
||||
// fix the GetBlock call returned errNotImplemented, surfacing on lux-mainnet as
|
||||
// "G chain ... failed to initialize VM: zap initialize: remote error: get last
|
||||
// accepted block: not implemented" and failing the node health check.
|
||||
func TestInitializeResolvesLastAcceptedBlock(t *testing.T) {
|
||||
require := require.New(t)
|
||||
ctx := context.Background()
|
||||
|
||||
vm := initVM(t, []byte(`{"defaultSchema":"type Query { hello: String }","schemaVersion":"1"}`))
|
||||
|
||||
// LastAccepted must be a real, non-empty block ID.
|
||||
lastAccepted, err := vm.LastAccepted(ctx)
|
||||
require.NoError(err)
|
||||
require.NotEqual(ids.Empty, lastAccepted)
|
||||
|
||||
// GetBlock(lastAccepted) is the call that returned "not implemented".
|
||||
blk, err := vm.GetBlock(ctx, lastAccepted)
|
||||
require.NoError(err)
|
||||
require.Equal(lastAccepted, blk.ID())
|
||||
require.Equal(uint64(0), blk.Height())
|
||||
require.Equal(ids.Empty, blk.Parent())
|
||||
require.NotEmpty(blk.Bytes())
|
||||
|
||||
// The ZAP server also reads Timestamp() for the InitializeResponse; it must
|
||||
// be deterministic (genesis epoch), never time.Now().
|
||||
require.Equal(genesisTimestamp, blk.Timestamp())
|
||||
|
||||
// GetBlockIDAtHeight(0) resolves to the same genesis block.
|
||||
at0, err := vm.GetBlockIDAtHeight(ctx, 0)
|
||||
require.NoError(err)
|
||||
require.Equal(lastAccepted, at0)
|
||||
}
|
||||
|
||||
// TestGenesisBlockRoundTrip proves Bytes()/ParseBlock are inverse and content-
|
||||
// addressed: re-parsing the genesis block yields a byte- and ID-identical block.
|
||||
// The engine relies on this when it re-parses the accepted frontier during
|
||||
// bootstrap.
|
||||
func TestGenesisBlockRoundTrip(t *testing.T) {
|
||||
require := require.New(t)
|
||||
ctx := context.Background()
|
||||
|
||||
vm := initVM(t, []byte(`{"schemaVersion":"1"}`))
|
||||
|
||||
lastAccepted, err := vm.LastAccepted(ctx)
|
||||
require.NoError(err)
|
||||
orig, err := vm.GetBlock(ctx, lastAccepted)
|
||||
require.NoError(err)
|
||||
|
||||
reparsed, err := vm.ParseBlock(ctx, orig.Bytes())
|
||||
require.NoError(err)
|
||||
require.Equal(orig.ID(), reparsed.ID())
|
||||
require.Equal(orig.Bytes(), reparsed.Bytes())
|
||||
require.Equal(orig.Height(), reparsed.Height())
|
||||
require.Equal(orig.Parent(), reparsed.Parent())
|
||||
require.Equal(orig.Timestamp(), reparsed.Timestamp())
|
||||
}
|
||||
|
||||
// TestUnknownBlockNotFound proves a non-genesis ID is reported as a genuine miss
|
||||
// (database.ErrNotFound), which the ZAP server maps to the wire NotFound code —
|
||||
// not the old errNotImplemented that broke initialization.
|
||||
func TestUnknownBlockNotFound(t *testing.T) {
|
||||
require := require.New(t)
|
||||
ctx := context.Background()
|
||||
|
||||
vm := initVM(t, []byte(`{"schemaVersion":"1"}`))
|
||||
|
||||
_, err := vm.GetBlock(ctx, ids.GenerateTestID())
|
||||
require.ErrorIs(err, database.ErrNotFound)
|
||||
|
||||
_, err = vm.GetBlockIDAtHeight(ctx, 1)
|
||||
require.ErrorIs(err, database.ErrNotFound)
|
||||
}
|
||||
|
||||
// TestGenesisDeterministic proves two independent VMs given identical genesis
|
||||
// bytes derive the SAME genesis block ID — the cross-validator agreement the
|
||||
// chain needs — while different genesis bytes derive different IDs.
|
||||
func TestGenesisDeterministic(t *testing.T) {
|
||||
require := require.New(t)
|
||||
ctx := context.Background()
|
||||
|
||||
idFor := func(genesis []byte) ids.ID {
|
||||
id, err := initVM(t, genesis).LastAccepted(ctx)
|
||||
require.NoError(err)
|
||||
return id
|
||||
}
|
||||
|
||||
a := []byte(`{"defaultSchema":"type Query { a: Int }","schemaVersion":"7"}`)
|
||||
b := []byte(`{"defaultSchema":"type Query { b: Int }","schemaVersion":"7"}`)
|
||||
|
||||
require.Equal(idFor(a), idFor(a))
|
||||
require.NotEqual(idFor(a), idFor(b))
|
||||
}
|
||||
Reference in New Issue
Block a user