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:
zeekay
2026-07-11 22:45:44 -07:00
co-authored by Hanzo Dev
parent dcde2167a8
commit 44d7651d16
3 changed files with 337 additions and 0 deletions
@@ -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")
}
+13
View File
@@ -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
+162
View File
@@ -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)
}
}