feat(rpc): close the ZAP caps handshake — plugin advertises CapQuasarExport + Quasar handlers; delete dead parallel scheme

The generic ZAP VM server never set InitializeResponse.Capabilities and had no MsgSetQuasarFinalized/
MsgQuasarHeight handlers, so CapQuasarExport was dead-on-arrival (every plugin reported Nova-only and
the node's Quasar-export bridge never wired for plugin VMs). newZAPVMServer now probes the wrapped VM
once for the quasarExporter interface (SetLastQuasarFinalized/LastQuasarHeight — the real C-Chain EVM
VM's method set), handleInitialize sets Capabilities accordingly, and the two Quasar handlers forward
to the VM (refuse loudly on a non-capable VM, no nil-panic). Deleted the parallel, never-wired
capabilities.go scheme (CurrentCapabilitiesVersion/VMKind/VMFeature — zero importers confirmed
repo-wide); InitializeResponse.Capabilities is the one caps mechanism.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
zeekay
2026-07-16 12:31:40 -07:00
co-authored by Hanzo Dev
parent 686efc6887
commit f39ce26833
3 changed files with 194 additions and 177 deletions
-175
View File
@@ -1,175 +0,0 @@
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package rpc
// VMKind represents the type of consensus data structure the VM supports.
type VMKind string
const (
// VMKindChain indicates a linear blockchain (single-parent blocks)
VMKindChain VMKind = "chain"
// VMKindDAG indicates a directed acyclic graph (multi-parent vertices)
VMKindDAG VMKind = "dag"
// VMKindHybrid indicates support for both chain and DAG
VMKindHybrid VMKind = "hybrid"
)
// VMFeature represents optional features a VM can support.
type VMFeature string
const (
// Core block/vertex operations
FeatureBuildBlock VMFeature = "build_block"
FeatureParseBlock VMFeature = "parse_block"
FeatureGetBlock VMFeature = "get_block"
FeatureVerifyBlock VMFeature = "verify_block"
FeatureAcceptBlock VMFeature = "accept_block"
FeatureRejectBlock VMFeature = "reject_block"
FeatureSetPreference VMFeature = "set_preference"
// DAG-specific operations
FeatureBuildVertex VMFeature = "build_vertex"
FeatureParseVertex VMFeature = "parse_vertex"
FeatureGetVertex VMFeature = "get_vertex"
FeatureVerifyVertex VMFeature = "verify_vertex"
FeatureAcceptVertex VMFeature = "accept_vertex"
FeatureRejectVertex VMFeature = "reject_vertex"
FeatureFrontierOps VMFeature = "frontier_ops"
// State and sync
FeatureStateSync VMFeature = "state_sync"
FeatureStateSummary VMFeature = "state_summary"
FeatureGetAncestors VMFeature = "get_ancestors"
// Batched operations
FeatureBatchedParse VMFeature = "batched_parse"
FeatureBatchedVerify VMFeature = "batched_verify"
// Context-aware operations
FeatureWithContext VMFeature = "with_context"
// Messaging
FeatureSender VMFeature = "sender"
FeatureHandler VMFeature = "handler"
)
// Capabilities describes what a VM supports.
type Capabilities struct {
// Kind indicates chain, dag, or hybrid
Kind VMKind `json:"kind"`
// Features lists supported optional features
Features []VMFeature `json:"features"`
// Version of the capability protocol
Version uint32 `json:"version"`
// MinProtocolVersion is the minimum RPC protocol version supported
MinProtocolVersion uint32 `json:"min_protocol_version"`
// MaxProtocolVersion is the maximum RPC protocol version supported
MaxProtocolVersion uint32 `json:"max_protocol_version"`
}
// CurrentCapabilitiesVersion is the current version of the capabilities protocol.
const CurrentCapabilitiesVersion uint32 = 1
// NewChainCapabilities returns default capabilities for a chain VM.
func NewChainCapabilities() *Capabilities {
return &Capabilities{
Kind: VMKindChain,
Version: CurrentCapabilitiesVersion,
MinProtocolVersion: 42,
MaxProtocolVersion: 42,
Features: []VMFeature{
FeatureBuildBlock,
FeatureParseBlock,
FeatureGetBlock,
FeatureVerifyBlock,
FeatureAcceptBlock,
FeatureRejectBlock,
FeatureSetPreference,
FeatureGetAncestors,
FeatureSender,
FeatureHandler,
},
}
}
// NewDAGCapabilities returns default capabilities for a DAG VM.
func NewDAGCapabilities() *Capabilities {
return &Capabilities{
Kind: VMKindDAG,
Version: CurrentCapabilitiesVersion,
MinProtocolVersion: 42,
MaxProtocolVersion: 42,
Features: []VMFeature{
FeatureBuildVertex,
FeatureParseVertex,
FeatureGetVertex,
FeatureVerifyVertex,
FeatureAcceptVertex,
FeatureRejectVertex,
FeatureFrontierOps,
FeatureGetAncestors,
FeatureSender,
FeatureHandler,
},
}
}
// NewHybridCapabilities returns capabilities for a VM that supports both chain and DAG.
func NewHybridCapabilities() *Capabilities {
caps := NewChainCapabilities()
caps.Kind = VMKindHybrid
// Add DAG features
dagCaps := NewDAGCapabilities()
caps.Features = append(caps.Features, dagCaps.Features...)
return caps
}
// SupportsFeature returns true if the VM supports the given feature.
func (c *Capabilities) SupportsFeature(feature VMFeature) bool {
for _, f := range c.Features {
if f == feature {
return true
}
}
return false
}
// SupportsChain returns true if the VM supports chain operations.
func (c *Capabilities) SupportsChain() bool {
return c.Kind == VMKindChain || c.Kind == VMKindHybrid
}
// SupportsDAG returns true if the VM supports DAG operations.
func (c *Capabilities) SupportsDAG() bool {
return c.Kind == VMKindDAG || c.Kind == VMKindHybrid
}
// WithFeature adds a feature and returns the capabilities for chaining.
func (c *Capabilities) WithFeature(feature VMFeature) *Capabilities {
if !c.SupportsFeature(feature) {
c.Features = append(c.Features, feature)
}
return c
}
// WithStateSync adds state sync capability.
func (c *Capabilities) WithStateSync() *Capabilities {
return c.WithFeature(FeatureStateSync).WithFeature(FeatureStateSummary)
}
// WithBatchedOps adds batched operation capability.
func (c *Capabilities) WithBatchedOps() *Capabilities {
return c.WithFeature(FeatureBatchedParse).WithFeature(FeatureBatchedVerify)
}
// WithContext adds context-aware operation capability.
func (c *Capabilities) WithContext() *Capabilities {
return c.WithFeature(FeatureWithContext)
}
+88 -2
View File
@@ -18,8 +18,8 @@ import (
zapwire "github.com/luxfi/api/zap"
"github.com/luxfi/consensus/engine/chain/block"
"github.com/luxfi/database"
"github.com/luxfi/database/zapdb"
"github.com/luxfi/database/prefixdb"
"github.com/luxfi/database/zapdb"
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/metric"
@@ -101,6 +101,24 @@ func Serve(ctx context.Context, logger log.Logger, vm chain.ChainVM) (retErr err
return err
}
// quasarExporter is the OPTIONAL VM-side surface behind zapwire.CapQuasarExport.
// A concrete ChainVM (the C-Chain EVM) implements it to track a Quasar
// (⅔-by-stake) EXPORT-FINAL height distinct from its reorgable local accept tip.
// It is NOT part of the generic chain.ChainVM contract: the plugin server PROBES
// the wrapped VM for it once at construction and, when present, advertises
// CapQuasarExport at the Initialize handshake AND serves the MsgSetQuasarFinalized
// / MsgQuasarHeight round-trips. The method set mirrors the node-side client
// (github.com/luxfi/node/vms/rpcchainvm/zap.Client) so the two ends compose
// across the ZAP boundary. Absent it, the node keeps this chain Nova-only.
type quasarExporter interface {
// SetLastQuasarFinalized records that the block at height reached ⅔-stake
// EXPORT finality (drives the VM's finalized/safe tags + warp export gate).
SetLastQuasarFinalized(height uint64)
// LastQuasarHeight returns the VM's accept-tip-clamped EXPORT-FINAL height
// (0 before the first export forms).
LastQuasarHeight() uint64
}
// zapVMServer implements zapwire.Handler for a VM
type zapVMServer struct {
vm chain.ChainVM
@@ -108,6 +126,13 @@ type zapVMServer struct {
allowShutdown *bool
db database.Database // persistent database, closed on shutdown
// quasarVM is the wrapped VM viewed through the OPTIONAL quasarExporter
// surface, or nil if the VM does not implement it. Resolved ONCE by probing vm
// at construction (a VM's method set is fixed for its life), then read by
// capabilities() and the two Quasar handlers. nil ⇒ the server never sets
// CapQuasarExport and refuses the Quasar messages, so the node stays Nova-only.
quasarVM quasarExporter
// pendingBlock caches the last built block to prevent rebuilding
// with a new timestamp while consensus is voting on it.
// Cleared on BlockAccept or BlockReject.
@@ -117,11 +142,32 @@ type zapVMServer struct {
func newZAPVMServer(vm chain.ChainVM, logger log.Logger) *zapVMServer {
allowShutdown := false
return &zapVMServer{
s := &zapVMServer{
vm: vm,
logger: logger,
allowShutdown: &allowShutdown,
}
// Probe the wrapped VM ONCE for the OPTIONAL Quasar export surface. A hit here
// is what turns CapQuasarExport from dead-on-arrival into a real capability: the
// bit is advertised at Initialize and the two Quasar handlers dispatch to this
// VM. A miss leaves quasarVM nil → generic VM, no export surface.
if qvm, ok := vm.(quasarExporter); ok {
s.quasarVM = qvm
}
return s
}
// capabilities returns the OPTIONAL cross-boundary add-on bitfield this server
// advertises in InitializeResponse.Capabilities, derived from the one-time probe
// of the wrapped VM. A generic VM implements no add-ons → 0 → the node stays on
// the generic path. This is the SINGLE source of the advertised bitfield; every
// bit set here MUST have a matching handler wired in Handle.
func (s *zapVMServer) capabilities() uint64 {
var caps uint64
if s.quasarVM != nil {
caps |= zapwire.CapQuasarExport
}
return caps
}
// Handle implements zapwire.Handler
@@ -176,6 +222,10 @@ func (s *zapVMServer) Handle(ctx context.Context, msgType zapwire.MessageType, p
return s.handleGetBlockIDAtHeight(ctx, payload)
case zapwire.MsgNewHTTPHandler:
return s.handleNewHTTPHandler(ctx)
case zapwire.MsgSetQuasarFinalized:
return s.handleSetQuasarFinalized(ctx, payload)
case zapwire.MsgQuasarHeight:
return s.handleQuasarHeight(ctx)
default:
s.logger.Warn("unknown ZAP message type", "msgType", msgType)
return msgType, nil, nil
@@ -277,6 +327,9 @@ func (s *zapVMServer) handleInitialize(ctx context.Context, payload []byte) (zap
Height: lastBlock.Height(),
Bytes: lastBlock.Bytes(),
Timestamp: lastBlock.Timestamp().UnixNano(),
// Capabilities is probed from the wrapped VM (0 for a generic VM). The
// node reads it once here to decide which OPTIONAL add-ons to wire.
Capabilities: s.capabilities(),
}
buf := zapwire.GetBuffer()
@@ -288,6 +341,7 @@ func (s *zapVMServer) handleInitialize(ctx context.Context, payload []byte) (zap
s.logger.Info("ZAP VM initialized successfully",
"lastAcceptedID", lastAccepted,
"height", lastBlock.Height(),
"quasarExport", s.quasarVM != nil,
)
return zapwire.MsgInitialize, result, nil
@@ -831,6 +885,38 @@ func (s *zapVMServer) handleNewHTTPHandler(ctx context.Context) (zapwire.Message
return zapwire.MsgNewHTTPHandler, result, nil
}
// handleSetQuasarFinalized applies a node-pushed Quasar (⅔-by-stake) EXPORT-FINAL
// height to the wrapped VM. Only reachable when the VM advertised CapQuasarExport
// (quasarVM != nil); a stray message on a non-capable VM is refused, not silently
// dropped. The response is empty — the node-side caller is fire-and-forget.
func (s *zapVMServer) handleSetQuasarFinalized(ctx context.Context, payload []byte) (zapwire.MessageType, []byte, error) {
if s.quasarVM == nil {
return zapwire.MsgSetQuasarFinalized, nil, fmt.Errorf("vm does not support quasar export")
}
req := &zapwire.SetQuasarFinalizedRequest{}
if err := req.Decode(zapwire.NewReader(payload)); err != nil {
return zapwire.MsgSetQuasarFinalized, nil, fmt.Errorf("decode set-quasar-finalized: %w", err)
}
s.quasarVM.SetLastQuasarFinalized(req.Height)
return zapwire.MsgSetQuasarFinalized, nil, nil
}
// handleQuasarHeight returns the wrapped VM's accept-tip-clamped Quasar
// EXPORT-FINAL height (0 before the first export). Only reachable when the VM
// advertised CapQuasarExport.
func (s *zapVMServer) handleQuasarHeight(ctx context.Context) (zapwire.MessageType, []byte, error) {
if s.quasarVM == nil {
return zapwire.MsgQuasarHeight, nil, fmt.Errorf("vm does not support quasar export")
}
resp := &zapwire.QuasarHeightResponse{Height: s.quasarVM.LastQuasarHeight()}
buf := zapwire.GetBuffer()
resp.Encode(buf)
result := make([]byte, len(buf.Bytes()))
copy(result, buf.Bytes())
zapwire.PutBuffer(buf)
return zapwire.MsgQuasarHeight, result, nil
}
func errorToZAP(err error) zapwire.Error {
if err == nil {
return zapwire.ErrorUnspecified
+106
View File
@@ -0,0 +1,106 @@
//go:build !grpc
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package rpc
import (
"context"
"testing"
zapwire "github.com/luxfi/api/zap"
"github.com/luxfi/log"
"github.com/luxfi/vm/chain"
)
// baseVM embeds chain.ChainVM so it satisfies the full generic contract with no
// boilerplate. The probe + Quasar handlers under test never invoke the embedded
// (nil) methods, so leaving them unimplemented is safe for these unit tests.
type baseVM struct{ chain.ChainVM }
// quasarVMStub is a ChainVM that ALSO implements the OPTIONAL quasarExporter
// surface the server probes for at construction.
type quasarVMStub struct {
baseVM
finalized uint64
height uint64
}
func (q *quasarVMStub) SetLastQuasarFinalized(h uint64) { q.finalized = h }
func (q *quasarVMStub) LastQuasarHeight() uint64 { return q.height }
// Compile-time contracts: both stubs are ChainVMs; only the quasar stub is a
// quasarExporter (so the server's probe distinguishes them).
var (
_ chain.ChainVM = baseVM{}
_ chain.ChainVM = (*quasarVMStub)(nil)
_ quasarExporter = (*quasarVMStub)(nil)
)
func TestCapabilitiesProbe(t *testing.T) {
logger := log.NewTestLogger(log.DebugLevel)
// Generic VM: no optional add-ons → Capabilities 0 → node stays Nova-only.
if got := newZAPVMServer(baseVM{}, logger).capabilities(); got != 0 {
t.Fatalf("generic VM must advertise 0 capabilities, got %d", got)
}
// Quasar-capable VM: probe hits → CapQuasarExport advertised.
if got := newZAPVMServer(&quasarVMStub{}, logger).capabilities(); got != zapwire.CapQuasarExport {
t.Fatalf("quasar VM must advertise CapQuasarExport (%d), got %d", zapwire.CapQuasarExport, got)
}
}
func TestQuasarHandlersRoundTrip(t *testing.T) {
logger := log.NewTestLogger(log.DebugLevel)
stub := &quasarVMStub{height: 99}
s := newZAPVMServer(stub, logger)
ctx := context.Background()
// MsgSetQuasarFinalized forwards the height into the wrapped VM.
req := &zapwire.SetQuasarFinalizedRequest{Height: 42}
buf := zapwire.GetBuffer()
req.Encode(buf)
payload := append([]byte(nil), buf.Bytes()...)
zapwire.PutBuffer(buf)
mt, resp, err := s.handleSetQuasarFinalized(ctx, payload)
if err != nil {
t.Fatalf("handleSetQuasarFinalized: %v", err)
}
if mt != zapwire.MsgSetQuasarFinalized || resp != nil {
t.Fatalf("set-quasar-finalized reply: mt=%d resp=%v", mt, resp)
}
if stub.finalized != 42 {
t.Fatalf("SetLastQuasarFinalized not applied: got %d want 42", stub.finalized)
}
// MsgQuasarHeight returns the wrapped VM's clamped height.
mt, resp, err = s.handleQuasarHeight(ctx)
if err != nil {
t.Fatalf("handleQuasarHeight: %v", err)
}
if mt != zapwire.MsgQuasarHeight {
t.Fatalf("quasar-height reply type: got %d", mt)
}
var hr zapwire.QuasarHeightResponse
if err := hr.Decode(zapwire.NewReader(resp)); err != nil {
t.Fatalf("decode QuasarHeightResponse: %v", err)
}
if hr.Height != 99 {
t.Fatalf("quasar height: got %d want 99", hr.Height)
}
}
// TestQuasarHandlersRefusedWhenUnsupported: a stray Quasar message on a generic
// VM is refused with an error, not silently dropped or a nil-pointer panic.
func TestQuasarHandlersRefusedWhenUnsupported(t *testing.T) {
s := newZAPVMServer(baseVM{}, log.NewTestLogger(log.DebugLevel))
if _, _, err := s.handleQuasarHeight(context.Background()); err == nil {
t.Fatal("handleQuasarHeight on a non-quasar VM must error")
}
if _, _, err := s.handleSetQuasarFinalized(context.Background(), nil); err == nil {
t.Fatal("handleSetQuasarFinalized on a non-quasar VM must error")
}
}