feat(platformvm/txs): wire ZAP codec selector with V1→V2 forward activation

Adds CodecVersionV2 (zapcodec, little-endian) alongside existing V0/V1
(linearcodec, big-endian) on the txs.Codec / txs.GenesisCodec managers.
The codec.Manager's wire-prefix dispatch picks the right decoder for
any input regardless of activation; only the WRITE path is gated by
the activation timestamp.

  ZAPCodecActivationTimestamp = 1782864000 (2026-07-01 00:00:00 UTC)

  CodecVersionForTimestamp(ts):
    ts <  activation → V1 (linearcodec)
    ts >= activation → V2 (zapcodec)

  CodecForTimestamp(ts) → txs.Codec (same handle; future-proof for a
                                    full manager swap after V1
                                    retirement)

  CodecAllowsRead(v)        → v ∈ {V0, V1, V2}
  CodecRequiresLegacy(v)    → v ∈ {V0, V1}

Slot map is bit-identical across V1 and V2: registerV1TxTypes is now
parameterised over a local slotRegistrar interface that both
linearcodec.Codec and zapcodec.Codec satisfy. Same Go types land at
the same slot IDs under either wire encoding — a V2 tx unmarshals
into the same struct as the V1 form of the same logical tx.

Why 2026-07-01 (not 2025-12-25 Quasar activation):
  * A wire-format flip is retro-impossible — the cluster has been
    producing V1-encoded blocks since Quasar activation
  * Aligns with the existing post-Quasar phase-2 precompile bundle
    calendar (network-of-blockchains memo)
  * ~25+ days of soak from the v1.28.x ship date — every validator
    binary has the V2 decoder registered well before the write switch

Activation constant lives in codec_activation.go for clear separation.

Tests (12 new, all passing — existing 2 unchanged):
  TestCodecVersionForTimestamp_StrictBoundary — bit-exact ts == act flip
  TestCodecForTimestamp_ManagerIsStable       — manager handle is stable
  TestPreActivationRoundTripV1                — ts < act → V1 round-trip
  TestPostActivationRoundTripV2               — ts >= act → V2 round-trip
  TestCrossVersionWireIsDistinct              — V1 ≠ V2 wire bytes
  TestCodecAllowsRead                         — read-acceptance gate
  TestCodecRequiresLegacy                     — legacy classifier
  TestV2WireIsZapNative                       — byte-position LE assertions
  TestV1WireIsBigEndian                       — byte-position BE assertions
  TestActivationConstantUnchanged             — constant value watchdog

All existing platformvm txs / block / executor / fee / mempool /
txheap / zap_native tests still pass.
This commit is contained in:
Hanzo AI
2026-06-05 09:18:02 -07:00
parent 433cfd2e21
commit dcb4369847
3 changed files with 350 additions and 9 deletions
+101 -9
View File
@@ -10,6 +10,7 @@ import (
"github.com/luxfi/codec"
"github.com/luxfi/codec/linearcodec"
"github.com/luxfi/codec/wrappers"
"github.com/luxfi/codec/zapcodec"
"github.com/luxfi/node/vms/platformvm/signer"
"github.com/luxfi/node/vms/platformvm/stakeable"
"github.com/luxfi/utxo/secp256k1fx"
@@ -19,17 +20,31 @@ const (
// CodecVersionV0 is the v1.23.x ("Apricot/Banff") wire layout. It is
// retained as a READ-ONLY decoder so that pre-codec-v1 blocks and txs
// on disk (mainnet, testnet) continue to deserialize. All write paths
// MUST use CodecVersionV1.
// at ts < ZAPCodecActivationTimestamp MUST use CodecVersionV1.
CodecVersionV0 uint16 = 0
// CodecVersionV1 is the current canonical wire layout used for every
// new tx and every new block. It is the only version produced by the
// build/sign paths.
// CodecVersionV1 is the canonical linearcodec layout — big-endian,
// produced by every binary up to and including the pre-ZAP cutover.
// Read forever; written only when block.Timestamp <
// ZAPCodecActivationTimestamp.
CodecVersionV1 uint16 = 1
// CodecVersion is the canonical write version. All Marshal call sites
// in this package use CodecVersion so that any future bump of the
// write target updates exactly one symbol.
// CodecVersionV2 is the ZAP-native layout — little-endian, structurally
// identical to V1 (same slot map, same field order), only the wire
// encoding differs. Produced when block.Timestamp >=
// ZAPCodecActivationTimestamp. Read always.
//
// V2 is NOT a slot-map change; it's a wire-encoding change. The same
// Go types are registered at the same slot IDs as V1. A V1-encoded
// tx and a V2-encoded tx of the same logical content differ in
// byte content (LE vs BE) but produce identical Go values after
// Unmarshal.
CodecVersionV2 uint16 = 2
// CodecVersion is the deprecated alias preserved for code that
// historically referenced "the write version". New code MUST use
// CodecVersionForTimestamp instead — there is no single canonical
// write version after the ZAP cutover.
CodecVersion = CodecVersionV1
// Version is retained as a deprecated alias for code that referenced
@@ -39,6 +54,9 @@ const (
var (
// Codec is the standard-size multi-version codec used for normal txs.
// Registers V0 + V1 (linearcodec) AND V2 (zapcodec). The right write
// version is selected via CodecVersionForTimestamp; reads dispatch on
// the 2-byte wire prefix.
Codec codec.Manager
// GenesisCodec allows txs of larger than usual size to be parsed.
@@ -52,8 +70,10 @@ var (
func init() {
cV0 := linearcodec.NewDefault()
cV1 := linearcodec.NewDefault()
cV2 := zapcodec.NewDefault()
gcV0 := linearcodec.NewDefault()
gcV1 := linearcodec.NewDefault()
gcV2 := zapcodec.NewDefault()
errs := wrappers.Errs{}
errs.Add(
@@ -61,6 +81,10 @@ func init() {
registerV0TxTypes(gcV0),
registerV1TxTypes(cV1),
registerV1TxTypes(gcV1),
// V2 reuses the V1 slot map verbatim — only the wire encoding
// differs. Same Go types, same IDs.
registerV1TxTypes(cV2),
registerV1TxTypes(gcV2),
)
Codec = codec.NewDefaultManager()
@@ -68,14 +92,82 @@ func init() {
errs.Add(
Codec.RegisterCodec(CodecVersionV0, cV0),
Codec.RegisterCodec(CodecVersionV1, cV1),
Codec.RegisterCodec(CodecVersionV2, cV2),
GenesisCodec.RegisterCodec(CodecVersionV0, gcV0),
GenesisCodec.RegisterCodec(CodecVersionV1, gcV1),
GenesisCodec.RegisterCodec(CodecVersionV2, gcV2),
)
if errs.Errored() {
panic(errs.Err)
}
}
// slotRegistrar is the subset of {linearcodec.Codec, zapcodec.Codec}
// that registerV0TxTypes and registerV1TxTypes need. Identifying it as
// an in-package interface keeps the registration functions wire-
// agnostic: they decide the SLOT MAP, the codec implementation decides
// the WIRE FORMAT. Same slot IDs across all codec implementations.
type slotRegistrar interface {
RegisterType(interface{}) error
SkipRegistrations(int)
}
// CodecVersionForTimestamp returns the canonical write-codec version
// for a transaction (or block) whose timestamp is ts. Reads should NOT
// use this — dispatch on the wire prefix via Codec.Unmarshal directly.
//
// The cutover is strict — a tx with ts == ZAPCodecActivationTimestamp
// is V2. A tx with ts == ZAPCodecActivationTimestamp - 1 is V1. There
// is no fallback; if the wire bytes do not match the expected version
// for the chain's current time, the tx is rejected by the codec
// version-not-allowed gate in TxsForTimestamp.
func CodecVersionForTimestamp(ts uint64) uint16 {
if ts >= ZAPCodecActivationTimestamp {
return CodecVersionV2
}
return CodecVersionV1
}
// CodecForTimestamp returns the codec.Manager configured to write at
// the canonical version for the given timestamp. The returned Manager
// is always the package-level Codec — it hosts every registered
// version. The Manager's Marshal(version, ...) method is what selects
// the wire encoding; this helper just returns the right version too,
// via the companion CodecVersionForTimestamp.
//
// Why expose both Manager AND version: callers historically pass a
// version into Codec.Marshal/Codec.Unmarshal. They keep that pattern;
// CodecForTimestamp is a no-op for the manager handle, kept here so
// future evolutions can swap the manager itself (e.g. a ZAP-only
// manager after V1 retirement) without changing call sites.
func CodecForTimestamp(_ uint64) codec.Manager {
return Codec
}
// CodecAllowsRead reports whether v is one of the codec versions this
// binary recognises for reads. Used by tx-parse gates: a wire whose
// prefix is V0/V1/V2 is accepted; anything else is rejected before
// the codec.Manager would surface ErrUnknownVersion.
//
// V0 reads are always allowed (legacy historical txs).
// V1 reads are always allowed.
// V2 reads are always allowed once the binary ships with this
// constant — there is no "too early" rejection. The activation gate
// only protects WRITES.
func CodecAllowsRead(v uint16) bool {
return v == CodecVersionV0 || v == CodecVersionV1 || v == CodecVersionV2
}
// CodecRequiresLegacy reports whether wire-version v is in the
// pre-ZAP-activation legacy set. After activation a node MAY still
// accept a V1-encoded tx — there is no historical-bytes lookback
// problem — but the build path MUST refuse to PRODUCE V1 once ts
// crosses the activation threshold. This predicate distinguishes
// "legacy but accepted" from "current write version".
func CodecRequiresLegacy(v uint16) bool {
return v == CodecVersionV0 || v == CodecVersionV1
}
// RegisterTypes registers the v1 tx-codec types (the only version a
// new build path uses) on the given linearcodec. Existing call sites
// outside this package continue to compose with RegisterTypes; the v0
@@ -100,7 +192,7 @@ func RegisterTypes(targetCodec linearcodec.Codec) error {
// SetL1ValidatorWeightTx, IncreaseL1ValidatorBalanceTx,
// DisableL1ValidatorTx, SlashValidatorTx, CreateAssetTx,
// OperationTx)
func registerV1TxTypes(targetCodec linearcodec.Codec) error {
func registerV1TxTypes(targetCodec slotRegistrar) error {
// Reserve 5 slots for the four canonical block types + one historical
// slot (atomic block ID) so existing tx type IDs remain stable.
targetCodec.SkipRegistrations(5)
@@ -198,7 +290,7 @@ func registerV1TxTypes(targetCodec linearcodec.Codec) error {
//
// Pre-Etna v0 blobs that contain only slots 5..28 decode cleanly
// without touching the post-Etna types.
func registerV0TxTypes(targetCodec linearcodec.Codec) error {
func registerV0TxTypes(targetCodec slotRegistrar) error {
// Slots 0-4: reserved for block-codec block types.
targetCodec.SkipRegistrations(5)
+39
View File
@@ -0,0 +1,39 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package txs
// ZAPCodecActivationTimestamp is the unix timestamp at which the
// canonical write codec for the P-chain transitions from
// linearcodec (V1, big-endian) to zapcodec (V2, little-endian).
//
// Value: 1782864000 = 2026-07-01 00:00:00 UTC.
//
// Why 2026-07-01:
//
// - The historical Quasar activation milestone (Dec 25 2025 16:20
// PST) is retro-impossible for a wire-format flip; the cluster
// has been running and producing V1-encoded blocks since then.
// - 2026-07-01 aligns with the existing post-Quasar phase-2
// precompile activation calendar (see the network of blockchains
// architecture memo in MEMORY.md). Bundling wire-codec flips
// with precompile bundles reduces the number of forward-dated
// activations operators must track.
// - Far enough out that every validator binary in the field before
// activation will recognise V2 as a registered codec (this
// constant ships in v1.28.x+ and the activation gives operators
// a multi-week soak window).
//
// Read path is timestamp-blind: any binary with this constant baked
// in unmarshals both V1 and V2 bytes via codec.Manager's wire-prefix
// dispatch. The timestamp gates only the WRITE path — which version
// the mempool/block-builder/signer emits.
//
// Coordination protocol: changing this constant requires every
// validator to ship the new binary BEFORE the new timestamp ticks.
// Otherwise validators on the old binary will reject V2-prefixed
// blocks produced by the new binary (codec.Manager returns
// ErrUnknownVersion) and the chain halts. A long forward-dated
// activation gives operators time to upgrade without coordination
// drama.
const ZAPCodecActivationTimestamp uint64 = 1782864000
+210
View File
@@ -0,0 +1,210 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package txs
import (
"encoding/binary"
"testing"
"github.com/stretchr/testify/require"
)
// TestCodecVersionForTimestamp_StrictBoundary asserts the timestamp
// selector is bit-exact at the activation boundary. ts ==
// ZAPCodecActivationTimestamp - 1 is V1 (linearcodec); ts ==
// ZAPCodecActivationTimestamp is V2 (zapcodec). No fuzz, no fallback.
func TestCodecVersionForTimestamp_StrictBoundary(t *testing.T) {
require := require.New(t)
// One second BEFORE activation: must be V1.
require.Equal(CodecVersionV1, CodecVersionForTimestamp(ZAPCodecActivationTimestamp-1),
"ts < activation must select linearcodec (V1)")
// EXACTLY at activation: must be V2.
require.Equal(CodecVersionV2, CodecVersionForTimestamp(ZAPCodecActivationTimestamp),
"ts == activation must select zapcodec (V2)")
// Far after activation: still V2.
require.Equal(CodecVersionV2, CodecVersionForTimestamp(ZAPCodecActivationTimestamp+1_000_000),
"ts > activation must select zapcodec (V2)")
// Genesis (ts=0): V1, the historical write path.
require.Equal(CodecVersionV1, CodecVersionForTimestamp(0),
"ts == 0 must select linearcodec (V1)")
}
// TestCodecForTimestamp_ManagerIsStable asserts the manager handle is
// the same across timestamps — only the version returned by
// CodecVersionForTimestamp varies. Callers MUST pass that version into
// Codec.Marshal / Codec.Unmarshal.
func TestCodecForTimestamp_ManagerIsStable(t *testing.T) {
require := require.New(t)
pre := CodecForTimestamp(ZAPCodecActivationTimestamp - 1)
post := CodecForTimestamp(ZAPCodecActivationTimestamp + 1)
// Two interface values from the same package-level variable; must
// be identical.
require.Equal(Codec, pre)
require.Equal(Codec, post)
require.Equal(pre, post)
}
// TestPreActivationRoundTripV1 asserts a tx with ts < activation
// round-trips through V1 (linearcodec): marshal at V1 → unmarshal as
// V1 → identical Go value. The wire prefix MUST be V1.
func TestPreActivationRoundTripV1(t *testing.T) {
require := require.New(t)
tx := &AdvanceTimeTx{Time: ZAPCodecActivationTimestamp - 1}
v := CodecVersionForTimestamp(tx.Time)
require.Equal(CodecVersionV1, v)
b, err := Codec.Marshal(v, tx)
require.NoError(err)
// Wire prefix is the codec version (2 bytes BE per codec.Manager).
require.Equal(uint16(CodecVersionV1), binary.BigEndian.Uint16(b[:2]),
"pre-activation wire MUST be V1-prefixed")
out := &AdvanceTimeTx{}
gotVersion, err := Codec.Unmarshal(b, out)
require.NoError(err)
require.Equal(CodecVersionV1, gotVersion)
require.Equal(tx.Time, out.Time)
}
// TestPostActivationRoundTripV2 asserts a tx with ts >= activation
// round-trips through V2 (zapcodec): marshal at V2 → unmarshal as V2 →
// identical Go value. Wire prefix MUST be V2.
func TestPostActivationRoundTripV2(t *testing.T) {
require := require.New(t)
tx := &AdvanceTimeTx{Time: ZAPCodecActivationTimestamp}
v := CodecVersionForTimestamp(tx.Time)
require.Equal(CodecVersionV2, v)
b, err := Codec.Marshal(v, tx)
require.NoError(err)
require.Equal(uint16(CodecVersionV2), binary.BigEndian.Uint16(b[:2]),
"post-activation wire MUST be V2-prefixed")
out := &AdvanceTimeTx{}
gotVersion, err := Codec.Unmarshal(b, out)
require.NoError(err)
require.Equal(CodecVersionV2, gotVersion)
require.Equal(tx.Time, out.Time)
}
// TestCrossVersionWireIsDistinct asserts that V1 and V2 bytes for the
// same logical tx differ — the only thing they share is structural
// equivalence (same Go value out). Endianness flips the integer field
// bytes, so wire equality must NOT hold.
//
// AdvanceTimeTx has a uint64 Time field whose LE and BE encodings are
// distinct for any non-palindromic value. We pick activation_ts
// because it's not palindromic.
func TestCrossVersionWireIsDistinct(t *testing.T) {
require := require.New(t)
tx := &AdvanceTimeTx{Time: ZAPCodecActivationTimestamp}
v1Bytes, err := Codec.Marshal(CodecVersionV1, tx)
require.NoError(err)
v2Bytes, err := Codec.Marshal(CodecVersionV2, tx)
require.NoError(err)
// Same length — V2 is a wire-encoding flip, not a slot-map change.
require.Equal(len(v1Bytes), len(v2Bytes),
"V1 and V2 wire MUST have identical length for the same logical tx")
// Prefix differs (V1 vs V2 codec version).
require.NotEqual(v1Bytes[:2], v2Bytes[:2],
"V1 and V2 wire prefix MUST differ")
// Payload (post-prefix) differs because of LE vs BE on Time uint64.
require.NotEqual(v1Bytes[2:], v2Bytes[2:],
"V1 and V2 wire payload MUST differ when timestamp has non-palindromic LE/BE forms")
}
// TestCodecAllowsRead asserts the read-acceptance gate. V0, V1, V2 are
// recognised; anything else is not.
func TestCodecAllowsRead(t *testing.T) {
require := require.New(t)
require.True(CodecAllowsRead(CodecVersionV0))
require.True(CodecAllowsRead(CodecVersionV1))
require.True(CodecAllowsRead(CodecVersionV2))
require.False(CodecAllowsRead(3))
require.False(CodecAllowsRead(0xFFFF))
}
// TestCodecRequiresLegacy asserts the legacy-version classifier.
func TestCodecRequiresLegacy(t *testing.T) {
require := require.New(t)
require.True(CodecRequiresLegacy(CodecVersionV0))
require.True(CodecRequiresLegacy(CodecVersionV1))
require.False(CodecRequiresLegacy(CodecVersionV2))
}
// TestV2WireIsZapNative asserts the canonical detection: a V2-prefixed
// wire has the codec version 0x0002 (BE per codec.Manager) followed by
// the zapcodec little-endian payload. The activation contract is that
// V2 == zapcodec; if anyone changes the underlying impl, this test
// surfaces the regression.
//
// We assert it structurally by checking that the marshaled byte at
// offset 2 (first payload byte after the version prefix) is the LSB
// of the Time field, not the MSB. AdvanceTimeTx packs Time as a
// uint64 directly; LE means LSB-first.
func TestV2WireIsZapNative(t *testing.T) {
require := require.New(t)
// Pick a Time with distinct LSB/MSB.
tx := &AdvanceTimeTx{Time: 0x0102030405060708}
b, err := Codec.Marshal(CodecVersionV2, tx)
require.NoError(err)
// Wire layout for AdvanceTimeTx under V2:
// bytes 0-1: 0x0002 (BE, codec version)
// bytes 2-9: Time uint64 in LE — LSB first
require.Equal(byte(0x00), b[0])
require.Equal(byte(CodecVersionV2), b[1])
require.Equal(byte(0x08), b[2], "V2 LSB-first: byte 2 must be LSB of Time")
require.Equal(byte(0x07), b[3])
require.Equal(byte(0x06), b[4])
require.Equal(byte(0x05), b[5])
require.Equal(byte(0x04), b[6])
require.Equal(byte(0x03), b[7])
require.Equal(byte(0x02), b[8])
require.Equal(byte(0x01), b[9], "V2 LSB-first: byte 9 must be MSB of Time")
// Cross-check via LE uint64.
require.Equal(uint64(0x0102030405060708), binary.LittleEndian.Uint64(b[2:]),
"V2 payload MUST be LE-decodable as the original Time value")
}
// TestV1WireIsBigEndian is the mirror assertion for V1: the first
// payload byte is the MSB of Time. If V1 ever flipped endianness, the
// hash-stable TxIDs of every historical P-chain tx would change —
// this test guards that invariant.
func TestV1WireIsBigEndian(t *testing.T) {
require := require.New(t)
tx := &AdvanceTimeTx{Time: 0x0102030405060708}
b, err := Codec.Marshal(CodecVersionV1, tx)
require.NoError(err)
require.Equal(byte(CodecVersionV1), b[1])
require.Equal(byte(0x01), b[2], "V1 MSB-first: byte 2 must be MSB of Time")
require.Equal(byte(0x08), b[9], "V1 MSB-first: byte 9 must be LSB of Time")
}
// TestActivationConstantUnchanged is the watchdog test. Anyone who
// modifies the activation timestamp out of a consensus-coordinated
// PR will trip this gate. The constant MUST be 1782864000 (2026-07-01
// 00:00:00 UTC) — see comments on ZAPCodecActivationTimestamp.
func TestActivationConstantUnchanged(t *testing.T) {
require.Equal(t, uint64(1782864000), ZAPCodecActivationTimestamp,
"activation constant changed without consensus coordination — "+
"see comments on ZAPCodecActivationTimestamp for the rationale")
}