Merge graphvm genesis last-accepted block (fixes G-chain ZAP init: GetLastAccepted not implemented)

This commit is contained in:
zeekay
2026-06-26 15:53:06 -07:00
3 changed files with 256 additions and 17 deletions
+85 -8
View File
@@ -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
View File
@@ -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
}
+125
View File
@@ -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))
}