mirror of
https://github.com/luxfi/node.git
synced 2026-07-27 03:39:39 +00:00
block: reject trailing bytes in Parse (malleability guard) + port determinism test to native ZAP
RED-verified consensus-safety fix: zap.Parse truncates to the header size field, so a block buffer with extra tail bytes wraps the SAME message but ID=hash(bytes) differs — a block-hash malleability / fork vector. setID now rejects msg.Size() != len(bytes) with ErrExtraSpace. (The P-chain TX envelope already guards this at tx.go:66; only block Parse had the gap.) Renamed codec_determinism_test.go -> block_determinism_test.go and converted its assertions to native ZAP: New*Block + Parse(b) (no Codec), native golden AbortBlock bytes (65B: zap header + kind/parent/height/time object), byte- stability + BlockID-stability + trailing-bytes-rejected. Block + txs green. Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package block
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/luxfi/constants"
|
||||
"github.com/luxfi/crypto/hash"
|
||||
"github.com/luxfi/ids"
|
||||
lux "github.com/luxfi/utxo"
|
||||
"github.com/luxfi/utxo/secp256k1fx"
|
||||
|
||||
"github.com/luxfi/node/vms/platformvm/txs"
|
||||
)
|
||||
|
||||
// The P-Chain block wire is native ZAP struct-is-wire (single format, no
|
||||
// codec). BlockIDs are hash(canonical block bytes) and inner TxIDs ride the
|
||||
// same encoding, so the block wire MUST be canonical + non-malleable. These
|
||||
// are the block-level determinism assertions RED verifies before re-genesis.
|
||||
|
||||
// blockDeterminismTx returns a signed decision tx for the tx-bearing block
|
||||
// fixtures. Signed with nil signers (no credentials) so it is fully
|
||||
// deterministic and CGO-independent.
|
||||
func blockDeterminismTx(t *testing.T) *txs.Tx {
|
||||
t.Helper()
|
||||
base := &lux.BaseTx{
|
||||
NetworkID: constants.MainnetID,
|
||||
BlockchainID: constants.PlatformChainID,
|
||||
Outs: []*lux.TransferableOutput{
|
||||
{
|
||||
Asset: lux.Asset{ID: ids.ID{0x09, 0x09, 0x09}},
|
||||
Out: &secp256k1fx.TransferOutput{
|
||||
Amt: constants.MilliLux,
|
||||
OutputOwners: secp256k1fx.OutputOwners{
|
||||
Threshold: 1,
|
||||
Addrs: []ids.ShortID{{0x01, 0x02, 0x03}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
unsigned, err := txs.NewBaseTx(base)
|
||||
require.NoError(t, err)
|
||||
tx := &txs.Tx{Unsigned: unsigned}
|
||||
require.NoError(t, tx.Initialize())
|
||||
return tx
|
||||
}
|
||||
|
||||
func blockDeterminismFixtures(t *testing.T) []struct {
|
||||
name string
|
||||
blk Block
|
||||
} {
|
||||
t.Helper()
|
||||
var (
|
||||
ts = time.Unix(1_700_000_000, 0)
|
||||
parent = ids.ID{0x0a, 0x0b, 0x0c, 0x0d}
|
||||
height = uint64(42)
|
||||
tx = blockDeterminismTx(t)
|
||||
)
|
||||
|
||||
abort, err := NewAbortBlock(ts, parent, height)
|
||||
require.NoError(t, err)
|
||||
commit, err := NewCommitBlock(ts, parent, height)
|
||||
require.NoError(t, err)
|
||||
standard, err := NewStandardBlock(ts, parent, height, []*txs.Tx{tx})
|
||||
require.NoError(t, err)
|
||||
proposal, err := NewProposalBlock(ts, parent, height, tx, []*txs.Tx{tx})
|
||||
require.NoError(t, err)
|
||||
|
||||
return []struct {
|
||||
name string
|
||||
blk Block
|
||||
}{
|
||||
{"AbortBlock", abort},
|
||||
{"CommitBlock", commit},
|
||||
{"StandardBlock", standard},
|
||||
{"ProposalBlock", proposal},
|
||||
}
|
||||
}
|
||||
|
||||
// TestBlockRoundTripByteStability proves, for every block type, that the
|
||||
// canonical bytes survive Parse unchanged and that the BlockID is stable.
|
||||
// This is the invariant that keeps the fleet from forking on block hashes.
|
||||
func TestBlockRoundTripByteStability(t *testing.T) {
|
||||
for _, f := range blockDeterminismFixtures(t) {
|
||||
f := f
|
||||
t.Run(f.name, func(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
wire := f.blk.Bytes()
|
||||
require.NotEmpty(wire)
|
||||
|
||||
parsed, err := Parse(wire)
|
||||
require.NoError(err)
|
||||
|
||||
// Byte-preserving read: Parse wraps the input verbatim.
|
||||
require.Equal(wire, parsed.Bytes(), "Parse must preserve block bytes")
|
||||
require.Equal(f.blk.ID(), parsed.ID(), "BlockID must be stable across parse")
|
||||
|
||||
// Re-parsing the parsed block's bytes reproduces the same ID —
|
||||
// the encoding is canonical (struct IS the wire; nothing to
|
||||
// re-marshal).
|
||||
reparsed, err := Parse(parsed.Bytes())
|
||||
require.NoError(err)
|
||||
require.Equal(f.blk.ID(), reparsed.ID(),
|
||||
"decode->decode is not stable; block encoding is non-canonical")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGoldenAbortBlock pins the exact wire bytes and BlockID of a fixed
|
||||
// AbortBlock. Any drift in block-wire layout (kind byte width/position, field
|
||||
// order/endianness, zap header) breaks this — which is the point: these bytes
|
||||
// are the chain commitment.
|
||||
func TestGoldenAbortBlock(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
var parent ids.ID
|
||||
for i := range parent {
|
||||
parent[i] = byte(i)
|
||||
}
|
||||
blk, err := NewAbortBlock(time.Unix(0x0102030405060708, 0), parent, 0x1122334455667788)
|
||||
require.NoError(err)
|
||||
|
||||
// Native-ZAP golden captured at the re-genesis cutover:
|
||||
// zap header (magic ZAP\0, version 2, root@16, size 65) + object
|
||||
// {kind=1 @16, ParentID @17, Height LE @49, Time LE @57}.
|
||||
golden := []byte{
|
||||
0x5a, 0x41, 0x50, 0x00, 0x02, 0x00, 0x00, 0x00,
|
||||
0x10, 0x00, 0x00, 0x00, 0x41, 0x00, 0x00, 0x00,
|
||||
0x01, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06,
|
||||
0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
|
||||
0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16,
|
||||
0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e,
|
||||
0x1f, 0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22,
|
||||
0x11, 0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02,
|
||||
0x01,
|
||||
}
|
||||
require.Equal(golden, blk.Bytes(), "AbortBlock golden wire bytes drifted")
|
||||
require.Equal(ids.ID(hash.ComputeHash256Array(golden)), blk.ID(),
|
||||
"BlockID = hash(wire) is not stable")
|
||||
}
|
||||
|
||||
// TestBlockTrailingBytesRejected is the block-level malleability guard: a
|
||||
// buffer with extra tail bytes wraps the same zap message but hashes to a
|
||||
// different ID, so Parse MUST reject it.
|
||||
func TestBlockTrailingBytesRejected(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
blk, err := NewCommitBlock(time.Unix(1_700_000_000, 0), ids.ID{0x01}, 7)
|
||||
require.NoError(err)
|
||||
|
||||
malleable := append(append([]byte{}, blk.Bytes()...), 0x00)
|
||||
_, err = Parse(malleable)
|
||||
require.ErrorIs(err, ErrExtraSpace,
|
||||
"trailing bytes on a block MUST be rejected")
|
||||
}
|
||||
@@ -23,6 +23,7 @@ package block
|
||||
// Sizes: abort/commit = 49, standard = 65, proposal = 73.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
@@ -101,14 +102,26 @@ func (b commonZapBlock) InitRuntime(rt *runtime.Runtime) {
|
||||
}
|
||||
}
|
||||
|
||||
// ErrExtraSpace is returned when a block buffer carries bytes beyond the
|
||||
// self-delimiting zap message — a malleability vector (ID = hash(bytes) would
|
||||
// change while the wrapped message is identical). Blocks MUST be canonical.
|
||||
var ErrExtraSpace = errors.New("block: trailing bytes after zap message")
|
||||
|
||||
// setID parses the final bytes into the block's zap buffer and derives
|
||||
// ID = hash(bytes). Used by both the New*Block constructors (fresh buffer) and
|
||||
// Parse (wire/disk buffer); the bytes are authoritative and never re-encoded.
|
||||
//
|
||||
// Rejects trailing bytes: zap.Parse truncates to the header size field, so a
|
||||
// buffer with extra tail bytes would wrap the same message but hash to a
|
||||
// different ID. The block wire is canonical + non-malleable — reject it.
|
||||
func (b *commonZapBlock) setID(bytes []byte) error {
|
||||
msg, err := zap.Parse(bytes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if msg.Size() != len(bytes) {
|
||||
return ErrExtraSpace
|
||||
}
|
||||
b.msg = msg
|
||||
b.id = hash.ComputeHash256Array(bytes)
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package zap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
zapwire "github.com/luxfi/api/zap"
|
||||
"github.com/luxfi/consensus/engine/chain/block"
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/log"
|
||||
luxruntime "github.com/luxfi/runtime"
|
||||
"github.com/luxfi/version"
|
||||
rpc "github.com/luxfi/vm/rpc"
|
||||
)
|
||||
|
||||
// These tests exercise the REAL cross-process export path that the live deploy
|
||||
// uses — node rpcchainvm *Client → ZAP wire → luxfi/vm/rpc server → the
|
||||
// wrapped VM — which is where the export tier had been silently dormant (the
|
||||
// node held the *Client, whose method set did not carry the Quasar calls, so the
|
||||
// chain manager's capability probe failed and the observer never fired). The
|
||||
// fake VM here stands in for the C-Chain EVM's concrete *VM: the server asserts
|
||||
// the SAME structural capability interface against it that it asserts against
|
||||
// *evm.VM, so this proves the plumbing end to end without the full EVM stack.
|
||||
|
||||
// baseFakeVM is a minimal block.ChainVM with a NON-nil GetBlock (so the server's
|
||||
// handleInitialize succeeds) and NO export capability.
|
||||
type baseFakeVM struct{}
|
||||
|
||||
func (baseFakeVM) Initialize(context.Context, block.Init) error { return nil }
|
||||
func (baseFakeVM) BuildBlock(context.Context) (block.Block, error) { return &fakeBlock{}, nil }
|
||||
func (baseFakeVM) ParseBlock(context.Context, []byte) (block.Block, error) { return &fakeBlock{}, nil }
|
||||
func (baseFakeVM) GetBlock(context.Context, ids.ID) (block.Block, error) { return &fakeBlock{}, nil }
|
||||
func (baseFakeVM) Shutdown(context.Context) error { return nil }
|
||||
func (baseFakeVM) NewHTTPHandler(context.Context) (http.Handler, error) { return nil, nil }
|
||||
func (baseFakeVM) SetState(context.Context, uint32) error { return nil }
|
||||
func (baseFakeVM) Version(context.Context) (string, error) { return "fake", nil }
|
||||
func (baseFakeVM) Connected(context.Context, ids.NodeID, *version.Application) error {
|
||||
return nil
|
||||
}
|
||||
func (baseFakeVM) Disconnected(context.Context, ids.NodeID) error { return nil }
|
||||
func (baseFakeVM) HealthCheck(context.Context) (block.HealthCheckResult, error) {
|
||||
var r block.HealthCheckResult
|
||||
return r, nil
|
||||
}
|
||||
func (baseFakeVM) GetBlockIDAtHeight(context.Context, uint64) (ids.ID, error) {
|
||||
return ids.Empty, nil
|
||||
}
|
||||
func (baseFakeVM) SetPreference(context.Context, ids.ID) error { return nil }
|
||||
func (baseFakeVM) LastAccepted(context.Context) (ids.ID, error) { return ids.Empty, nil }
|
||||
func (baseFakeVM) WaitForEvent(context.Context) (block.Message, error) {
|
||||
var m block.Message
|
||||
return m, nil
|
||||
}
|
||||
|
||||
var _ block.ChainVM = baseFakeVM{}
|
||||
|
||||
// exportFakeVM adds the OPTIONAL export capability — the shape of the C-Chain EVM.
|
||||
type exportFakeVM struct {
|
||||
baseFakeVM
|
||||
mu sync.Mutex
|
||||
height uint64
|
||||
}
|
||||
|
||||
func (v *exportFakeVM) SetLastQuasarFinalized(h uint64) {
|
||||
v.mu.Lock()
|
||||
v.height = h
|
||||
v.mu.Unlock()
|
||||
}
|
||||
|
||||
func (v *exportFakeVM) LastQuasarHeight() uint64 {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
return v.height
|
||||
}
|
||||
|
||||
type fakeBlock struct{}
|
||||
|
||||
func (*fakeBlock) ID() ids.ID { return ids.Empty }
|
||||
func (*fakeBlock) Parent() ids.ID { return ids.Empty }
|
||||
func (*fakeBlock) ParentID() ids.ID { return ids.Empty }
|
||||
func (*fakeBlock) Height() uint64 { return 0 }
|
||||
func (*fakeBlock) Timestamp() time.Time { return time.Unix(0, 0) }
|
||||
func (*fakeBlock) Status() uint8 { return 0 }
|
||||
func (*fakeBlock) Bytes() []byte { return []byte{} }
|
||||
func (*fakeBlock) Verify(context.Context) error { return nil }
|
||||
func (*fakeBlock) Accept(context.Context) error { return nil }
|
||||
func (*fakeBlock) Reject(context.Context) error { return nil }
|
||||
|
||||
var _ block.Block = (*fakeBlock)(nil)
|
||||
|
||||
// newQuasarClient stands up the REAL luxfi/vm/rpc server (via NewZAPHandler)
|
||||
// wrapping vm, connects a REAL node *Client over an in-process ZAP link, and runs
|
||||
// the real Initialize handshake (which is where the capability is advertised).
|
||||
func newQuasarClient(t *testing.T, vm block.ChainVM) *Client {
|
||||
t.Helper()
|
||||
addr, stop := startTestServer(t, rpc.NewZAPHandler(vm, log.NewNoOpLogger()))
|
||||
t.Cleanup(stop)
|
||||
|
||||
conn, err := zapwire.Dial(context.Background(), addr, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Dial: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
|
||||
c := NewClient(conn, log.NewNoOpLogger())
|
||||
rt := &luxruntime.Runtime{NetworkID: 1337, ChainDataDir: t.TempDir()}
|
||||
if err := c.Initialize(context.Background(), block.Init{Runtime: rt, Genesis: []byte("{}")}); err != nil {
|
||||
t.Fatalf("Initialize: %v", err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// TestQuasarExport_CrossProcess_ReachesVM is the integration guard for the
|
||||
// deploy-blocking gap: with an export-capable VM behind the plugin boundary, the
|
||||
// node *Client (a) captures the capability from the Initialize handshake, (b)
|
||||
// forwards a pushed export height ACROSS the wire into the VM, and (c) reads the
|
||||
// VM's height back across the wire. Before the fix the *Client carried none of
|
||||
// this and the whole export tier stuck at genesis in the real plugin deploy.
|
||||
func TestQuasarExport_CrossProcess_ReachesVM(t *testing.T) {
|
||||
vm := &exportFakeVM{}
|
||||
c := newQuasarClient(t, vm)
|
||||
|
||||
if !c.SupportsQuasarExport() {
|
||||
t.Fatal("client did not capture CapQuasarExport from the Initialize handshake")
|
||||
}
|
||||
|
||||
// Push crosses the wire and reaches the concrete VM (what the chain manager's
|
||||
// QuasarObserver does on every ⅔-stake export-frontier advance).
|
||||
c.SetLastQuasarFinalized(42)
|
||||
if got := vm.LastQuasarHeight(); got != 42 {
|
||||
t.Fatalf("SetLastQuasarFinalized did not reach the VM across the boundary: got %d want 42", got)
|
||||
}
|
||||
|
||||
// Read-back crosses the wire (what the chain manager's boot re-seed does).
|
||||
if got := c.LastQuasarHeight(); got != 42 {
|
||||
t.Fatalf("LastQuasarHeight did not round-trip the VM's height: got %d want 42", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuasarExport_CrossProcess_NonCapableGraceful proves the OTHER half of the
|
||||
// capability gate: a generic plugin (no export methods) advertises nothing, so
|
||||
// the *Client reports not-capable and the export calls are graceful no-ops — the
|
||||
// chain manager therefore leaves the observer unwired and the chain runs
|
||||
// Nova-only, with no per-finalization cross-process traffic.
|
||||
func TestQuasarExport_CrossProcess_NonCapableGraceful(t *testing.T) {
|
||||
c := newQuasarClient(t, baseFakeVM{})
|
||||
|
||||
if c.SupportsQuasarExport() {
|
||||
t.Fatal("generic VM must not advertise CapQuasarExport")
|
||||
}
|
||||
// No panic, no RPC, sentinel height.
|
||||
c.SetLastQuasarFinalized(42)
|
||||
if got := c.LastQuasarHeight(); got != 0 {
|
||||
t.Fatalf("non-capable client must report 0 (empty frontier), got %d", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user