fix(zap): length-tolerant InitializeResponse decode (evolvable VM wire)

InitializeResponse.Decode now reads the trailing Capabilities field only when >=8 bytes remain, so a
newer node decoding a pre-v1.0.16 plugin's short payload yields Capabilities=0 instead of the
unexpected-EOF that broke every EVM Initialize in the v1.36.11 skew (Capabilities appended at protocol
42 without a bump). Appending a future trailing field can no longer EOF-break an older peer. New test
locks the tolerance.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
zeekay
2026-07-16 12:31:15 -07:00
co-authored by Hanzo Dev
parent a856555c3d
commit 59c8f2c92a
2 changed files with 105 additions and 3 deletions
+27 -3
View File
@@ -144,7 +144,23 @@ func (m *InitializeResponse) Encode(buf *Buffer) {
buf.WriteUint64(m.Capabilities)
}
// Decode deserializes InitializeResponse from the reader
// Decode deserializes InitializeResponse from the reader.
//
// LENGTH-TOLERANT trailing field. Capabilities is OPTIONAL on the wire: it was
// APPENDED in api v1.0.16 (the Quasar-export handshake) WITHOUT bumping
// version.RPCChainVMProtocol, so a peer built before it — a stale VM plugin —
// sends a payload that ends right after Timestamp. Guarding the read on
// Remaining() lets a newer decoder read that short payload as "Capabilities = 0"
// (Nova-only, no optional add-ons) instead of failing with io.ErrUnexpectedEOF —
// exactly the v1.36.11 skew that broke every EVM Initialize with
// "zap decode initialize response: unexpected EOF". The mirror case (an OLDER
// decoder reading a NEWER payload) already works: a length-prefixed ZAP frame
// lets the old decoder stop after Timestamp and ignore the trailing bytes.
//
// EVOLUTION RULE: any FUTURE field is appended AFTER Capabilities and read the
// same way — guard each read on Remaining(), and keep Encode writing fields in
// this exact order. Never insert or reorder a field in the middle: that is a
// breaking wire change and MUST bump version.RPCChainVMProtocol.
func (m *InitializeResponse) Decode(r *Reader) error {
var err error
if m.LastAcceptedID, err = r.ReadBytes(); err != nil {
@@ -162,8 +178,16 @@ func (m *InitializeResponse) Decode(r *Reader) error {
if m.Timestamp, err = r.ReadInt64(); err != nil {
return err
}
m.Capabilities, err = r.ReadUint64()
return err
// OPTIONAL trailing field: absent from pre-v1.0.16 peers. Remaining() < 8
// (a uint64 is 8 bytes) ⇒ leave Capabilities at its zero value rather than
// EOF. A count of exactly 8 (or more, from an even-newer peer with further
// appended fields) ⇒ read it and ignore anything past it.
if r.Remaining() >= 8 {
if m.Capabilities, err = r.ReadUint64(); err != nil {
return err
}
}
return nil
}
// SetStateRequest contains state change request.
+78
View File
@@ -0,0 +1,78 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package zap
import "testing"
// TestInitializeResponseDecodeLengthTolerant proves the OPTIONAL trailing
// Capabilities field can be absent on the wire without breaking Decode — the
// forward/backward-compatible property that fixes the v1.36.11 "unexpected EOF"
// skew, where a node built with the Capabilities field decoded an InitializeResponse
// from a stale plugin that never encoded it.
func TestInitializeResponseDecodeLengthTolerant(t *testing.T) {
lastID := []byte("last-accepted-id-32-bytes-000000")
parentID := []byte("parent-accepted-id-32-bytes-0000")
const height = uint64(12345)
blockBytes := []byte("block-bytes")
const ts = int64(1_700_000_000_000_000_000)
// PRE-v1.0.16 payload: the five original fields only, exactly as a stale
// plugin (built before Capabilities existed) encodes it.
old := GetBuffer()
old.WriteBytes(lastID)
old.WriteBytes(parentID)
old.WriteUint64(height)
old.WriteBytes(blockBytes)
old.WriteInt64(ts)
oldPayload := append([]byte(nil), old.Bytes()...)
PutBuffer(old)
var got InitializeResponse
if err := got.Decode(NewReader(oldPayload)); err != nil {
t.Fatalf("decode of pre-Capabilities payload must not error, got: %v", err)
}
if got.Capabilities != 0 {
t.Fatalf("absent Capabilities must default to 0, got %d", got.Capabilities)
}
if string(got.LastAcceptedID) != string(lastID) ||
string(got.LastAcceptedParentID) != string(parentID) ||
got.Height != height ||
string(got.Bytes) != string(blockBytes) ||
got.Timestamp != ts {
t.Fatalf("non-optional fields corrupted by tolerant decode: %+v", got)
}
// FULL round-trip: Capabilities present survives Encode -> Decode.
want := InitializeResponse{
LastAcceptedID: lastID,
LastAcceptedParentID: parentID,
Height: height,
Bytes: blockBytes,
Timestamp: ts,
Capabilities: CapQuasarExport,
}
buf := GetBuffer()
want.Encode(buf)
full := append([]byte(nil), buf.Bytes()...)
PutBuffer(buf)
var rt InitializeResponse
if err := rt.Decode(NewReader(full)); err != nil {
t.Fatalf("round-trip decode: %v", err)
}
if rt.Capabilities != CapQuasarExport {
t.Fatalf("Capabilities round-trip: got %d want %d", rt.Capabilities, CapQuasarExport)
}
// FORWARD-compat: an even-newer peer appends further trailing bytes; Decode
// reads Capabilities and ignores the rest without erroring.
fwd := append(append([]byte(nil), full...), 0xDE, 0xAD, 0xBE, 0xEF)
var fr InitializeResponse
if err := fr.Decode(NewReader(fwd)); err != nil {
t.Fatalf("forward-compat decode must ignore trailing bytes, got: %v", err)
}
if fr.Capabilities != CapQuasarExport {
t.Fatalf("forward-compat Capabilities: got %d want %d", fr.Capabilities, CapQuasarExport)
}
}