#189: LP-023 batch 5 v3.6 — R7V5 mempool admission gate for zap_native (Path A) + R7V7 Verify() for RegisterL1ValidatorTx + ConvertNetworkToL1Tx (HIGH) + R7V8 MustVerify rename + CI gate (MEDIUM)

Red round 7 closes the activation gap so Neo's ZAPActivationUnix=0 image can
land without zombie txs from not-yet-implemented zap_native executors.

R7V5 (HIGH, mempool admission gate — Path A)
  - vms/platformvm/network/zap_native_admission.go: new file. Wraps
    TxVerifier with NewZapNativeAdmissionGate; refuses
    *txs.CreateSovereignL1Tx (legacy struct dispatch hits stub at
    standard_tx_executor.go:636) AND any ZAP-magic wire buffer whose
    kind is CreateSovereignL1 (23), RegisterL1Validator (7), or
    ConvertNetworkToL1 (22).
  - vms/platformvm/network/network.go: New() now wraps the supplied
    TxVerifier with the gate before installing into gossipMempool, so
    every inbound tx routes through the gate first.
  - Typed error ErrZapNativeNotYetExecutable for errors.Is matching.
  - REMOVAL CHECKLIST documented inline so a future Blue knows to
    delete the gate entry when an executor body lands.
  - Tests in zap_native_admission_test.go: rejects legacy
    CreateSovereignL1Tx; passes through legacy BaseTx /
    RegisterL1ValidatorTx / ConvertNetworkToL1Tx (the working ones);
    rejects ZAP-wire CreateSovereignL1 / RegisterL1Validator /
    ConvertNetworkToL1; non-ZAP bytes pass; nil-safety.

R7V7 (HIGH, Verify() for two missing tx types)
  - RegisterL1ValidatorTx.Verify(): BLS PoP pairing + zero-Expiry gate
    (ErrZeroExpiry). RemainingBalanceOwnerID treated as optional v3
    placeholder.
  - ConvertNetworkToL1Tx.Verify(): same per-validator walk as
    CreateSovereignL1Tx (non-empty Validators, Weight>0, BLS PoP
    pairing).
  - Tests in r7v7_register_convert_verify_test.go: rejects bad BLS
    PoP / malformed BLS pubkey / zero Expiry / zero Validators / zero
    Weight; accepts valid; adversarial wire-buffer tampering for both
    tx types (zero out Expiry / Weight in-place and re-Wrap).

R7V8 (MEDIUM, MustVerify rename + CI gate)
  - chains_list.go: ChainsListView.Verify renamed to MustVerify so
    the per-list gate is grep-able from CI. The previous Verify()
    name collided with the tx-level Verify() convention.
  - tx_verify.go: CreateSovereignL1Tx.Verify call site updated.
  - r6_verify_test.go: TestChainsListView_Verify_StandaloneEntries
    renamed + updated to use .MustVerify().
  - audit_test.go: new TestAuditGate_ChainsListEmbeddersCallMustVerify
    enumerates tx types with a Chains() accessor returning ChainsList
    and confirms tx_verify.go has a corresponding Verify() body that
    calls .MustVerify().
  - .github/workflows/zap-audit.yml: new chainslist-verify-gate job
    mirroring the local audit_test.go invariant.

Test results (GOWORK=off, race enabled):
  ./vms/platformvm/network/...     PASS  (8 new + all existing)
  ./vms/platformvm/txs/zap_native/ PASS  (8 new + all existing)
  ./vms/platformvm/txs/executor/   PASS
  ./vms/platformvm/txs/             PASS
  ./vms/platformvm/block/...        PASS
This commit is contained in:
Hanzo AI
2026-06-02 19:28:40 -07:00
parent 1104e6776e
commit d0516c52c3
9 changed files with 1122 additions and 16 deletions
+63 -5
View File
@@ -1,16 +1,24 @@
name: zap-audit
# LP-023 R6-6: AddressList.At() must remain non-production until owner-model
# migration / executor-side equivalence is proven. This workflow fails the
# PR if any new production caller is introduced.
# LP-023 — audit gates for zap_native invariants.
#
# Allowed callers:
# Job 1 (R6-6): AddressList.At() must remain non-production until
# owner-model migration / executor-side equivalence is proven. This
# workflow fails the PR if any new production caller is introduced.
#
# Job 2 (R7V8): Every tx type that EMBEDS a ChainsList in its per-tx
# Verify() body MUST call .MustVerify() on it. The receiver-name rename
# (Verify → MustVerify, batch 5 v3.6) makes the gate grep-able. Without
# this gate, a future Verify() author can silently skip the per-entry
# FxIDsLen + reserved-bytes walk and ship a wire-fork primitive.
#
# Allowed callers (Job 1):
# - *_test.go — tests
# - tx_verify.go — executor-side SyntacticVerify boundary
# - owner.go — the type's own implementation
# - audit_test.go — the mirroring local test that reproduces this gate
#
# To LEGITIMATELY introduce a production caller:
# To LEGITIMATELY introduce a production caller (Job 1):
# 1. Land the owner-model migration that retires AddressList.At() OR
# 2. Add the caller's file to the allowlist below AND document the
# equivalence proof in vms/platformvm/txs/zap_native/owner.go.
@@ -67,3 +75,53 @@ jobs:
exit 1
fi
echo "Audit clean: zero AddressList.At() production consumers"
chainslist-verify-gate:
name: ChainsList embedders — MustVerify() required
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Ensure ChainsList embedders call MustVerify()
shell: bash
run: |
set -euo pipefail
cd vms/platformvm/txs/zap_native
# Step 1: enumerate tx types that EMBED ChainsList. A tx type
# T is an embedder if it has an accessor like
# func (t T) Chains() ChainsListView
# defined in a production file.
embedders=$(grep -rEoh 'func \(t [A-Z][A-Za-z0-9_]+\) Chains\(\) (ChainsList|ChainsListView|BoundChainsList)' \
--include='*.go' . \
| grep -vE '(_test\.go)' \
| sed -E 's/^func \(t ([A-Za-z0-9_]+)\) Chains.*/\1/' \
| sort -u || true)
if [ -z "$embedders" ]; then
echo "Audit clean: zero ChainsList embedder tx types"
exit 0
fi
# Step 2: for each embedder T, confirm tx_verify.go has a
# Verify() method for T AND calls .MustVerify(). Centralized
# in tx_verify.go by convention; embedder file (type
# definition) and Verify file (gate body) are decoupled.
offenders=""
for T in $embedders; do
if grep -qE "func \([a-z]+ ${T}\) Verify\(\)" tx_verify.go; then
if ! grep -q '\.MustVerify(' tx_verify.go; then
offenders="${offenders}\n${T} (tx_verify.go has Verify() for ${T} but no .MustVerify() call)"
fi
fi
done
if [ -n "$offenders" ]; then
echo "ERROR: ChainsList embedder tx type(s) missing .MustVerify() call."
echo "Every tx type T with a Chains() accessor AND a Verify()"
echo "method MUST call list.MustVerify() inside that Verify()"
echo "to enforce FxIDsLen + reserved-bytes invariants"
echo "(R6-4 / R6V5 / R7V8)."
echo ""
echo "Either wire the gate or document why it's safe to skip"
echo "inline with a clear comment."
echo ""
echo -e "Offenders:${offenders}"
exit 1
fi
echo "Audit clean: all ChainsList embedders call MustVerify()"
+9 -1
View File
@@ -95,11 +95,19 @@ func New(
return nil, err
}
// LP-023 R7V5: wrap the verifier so zap_native tx kinds whose
// executor body is not-yet-implemented are refused at the mempool
// admission boundary. The gate fires BEFORE state-machine execution
// so a not-yet-implemented kind never becomes a zombie tx in the
// mempool during Neo's ZAP-activation=0 rollout. Removal checklist
// lives in vms/platformvm/network/zap_native_admission.go.
gatedVerifier := NewZapNativeAdmissionGate(txVerifier)
gossipMempool, err := newGossipMempool(
mempool,
registerer,
log,
txVerifier,
gatedVerifier,
config.ExpectedBloomFilterElements,
config.ExpectedBloomFilterFalsePositiveProbability,
config.MaxBloomFilterFalsePositiveProbability,
@@ -0,0 +1,200 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package network
import (
"errors"
"fmt"
"github.com/luxfi/node/vms/platformvm/txs"
"github.com/luxfi/node/vms/platformvm/txs/zap_native"
)
// LP-023 Red round 7 R7V5 — mempool admission gate for zap_native tx
// kinds whose executor body is not-yet-implemented.
//
// Threat model (Neo's ZAP-activation=0 rollout):
//
// As of v1.28.19, ZAP-native wire format is mandatory from genesis
// (ZAPActivationUnix=0, codec_select.go). The mempool starts accepting
// ZAP-shaped tx bytes the moment the image lands. If a tx kind whose
// executor body is still a stub (returns "not yet implemented") enters
// the mempool, it CANNOT execute — but it CAN sit there forever,
// gossiped between nodes, consuming RAM, and (worst case) re-tried by
// validators on every block-build cycle until it's manually evicted.
//
// The cleanest defense is to refuse at the admission boundary. The
// wire-decode path stays canonical (parses confirm the buffer is
// well-formed); only the EXECUTION path is gated. Once a stub executor
// body lands (batch 6 or later), the corresponding entry can be removed
// from the gate.
//
// Gate covers BOTH today's path (legacy txs.* struct Visit dispatch
// hits the standard_tx_executor stub at line 636) AND tomorrow's path
// (zap_native wire bytes parse-and-dispatch through a future wire-aware
// admission flow). The double coverage is intentional: we don't need
// to track which path is "live" — we refuse on either.
//
// REMOVAL CHECKLIST (when a stub executor body lands):
//
// 1. Implement the executor body in
// vms/platformvm/txs/executor/standard_tx_executor.go (current
// CreateSovereignL1Tx stub at line 636).
// 2. Remove the matching case from IsZapNativeNotYetExecutable below.
// 3. Drop the matching test from zap_native_admission_test.go.
// 4. Document the executor-body PR in LP-023 batch-6 (or later) so the
// reviewer can cross-check the gate removal against the body landing.
//
// Until those steps are complete, every tx of the gated kinds is
// rejected at admission. Validators NEVER accept them; gossip NEVER
// propagates them; the mempool NEVER holds them.
// ErrZapNativeNotYetExecutable is returned at mempool admission when a tx
// is of a zap_native kind whose executor body is still a stub. Wire-decode
// path is canonical (the buffer is well-formed); execution is gated. See
// the package comment for the removal checklist.
//
// Wrapped per-kind with the tx-kind name for context; consumers match
// the bare error via errors.Is.
var ErrZapNativeNotYetExecutable = errors.New(
"zap_native: tx kind executor not yet implemented; rejected at mempool admission " +
"to prevent zombie txs during Neo ZAP-activation=0 rollout (LP-023 R7V5)",
)
// notYetExecutableLegacyKinds returns the set of legacy txs.UnsignedTx
// concrete types whose corresponding zap_native tx kind has no executor
// body yet. The legacy struct is what enters Visit-dispatch today; the
// zap_native wire kind is what tomorrow's parser would route through.
//
// TODAY: only txs.CreateSovereignL1Tx has a not-yet-implemented stub.
// The legacy ConvertNetworkToL1Tx (line 639) and RegisterL1ValidatorTx
// (line 761) executors are wired and working. They're listed in the
// brief because the zap_native wire variants of those types may still
// route through paths that hit the stubbed code — defense-in-depth: the
// gate covers all three even though only one has a live stub. When the
// legacy executors definitively cover the zap_native code path, the
// non-CreateSovereignL1 entries can be dropped.
//
// DO NOT add map lookups here in the hot path — IssueTxFromRPC is on
// the critical path of every incoming RPC tx. Use a type switch.
func isLegacyTxKindNotYetExecutable(tx *txs.Tx) bool {
if tx == nil || tx.Unsigned == nil {
return false
}
switch tx.Unsigned.(type) {
case *txs.CreateSovereignL1Tx:
// standard_tx_executor.go:635 — body returns
// "CreateSovereignL1Tx executor: not yet implemented (follow-up PR)".
// Gating at admission means the not-yet-implemented error never
// even fires; the tx is refused before the executor sees it.
return true
}
return false
}
// isZapNativeWireKindNotYetExecutable inspects the signed tx bytes. If
// they parse as a ZAP-native wire buffer (4-byte "ZAP\x00" magic) AND
// the kind discriminator is one of the not-yet-implemented set, the gate
// fires. This is the FUTURE-PROOFING half of the defense — today no
// production path routes ZAP-shaped bytes through the
// vms/platformvm/network admission flow, but the moment that path lands
// (batch 6 zap_native parser integration), this check covers it without
// a second edit.
//
// Gate set per brief: CreateSovereignL1 (kind 23), RegisterL1Validator
// (kind 7), ConvertNetworkToL1 (kind 22). The brief is explicit that
// the executor coverage gap exists for all three at the zap_native wire
// level — even though the legacy struct executors for kinds 7 and 22
// work, the zap_native wire bytes don't necessarily route through them.
//
// Returns the zap_native kind that triggered the gate (for the wrapped
// error message). Returns TxKindReserved (0) when no gate fires —
// callers compare against 0 to short-circuit.
func zapNativeWireKindNotYetExecutable(signedBytes []byte) zap_native.TxKind {
if !zap_native.IsZAPBytes(signedBytes) {
return zap_native.TxKindReserved
}
// Try to wrap as each gated kind. Each Wrap*Tx confirms the kind
// discriminator matches — wrong kind returns ErrWrongTxKind and we
// move to the next. Cheap because parseAndCheckKind does a single
// header parse + byte compare.
if _, err := zap_native.WrapCreateSovereignL1Tx(signedBytes); err == nil {
return zap_native.TxKindCreateSovereignL1
}
if _, err := zap_native.WrapRegisterL1ValidatorTx(signedBytes); err == nil {
return zap_native.TxKindRegisterL1Validator
}
if _, err := zap_native.WrapConvertNetworkToL1Tx(signedBytes); err == nil {
return zap_native.TxKindConvertNetworkToL1
}
return zap_native.TxKindReserved
}
// zapNativeKindName returns a human-readable name for a TxKind. Only
// the gated kinds are listed — every other kind returns "unknown".
// Used only in the gate's error message; not on any hot path.
func zapNativeKindName(k zap_native.TxKind) string {
switch k {
case zap_native.TxKindCreateSovereignL1:
return "CreateSovereignL1Tx"
case zap_native.TxKindRegisterL1Validator:
return "RegisterL1ValidatorTx"
case zap_native.TxKindConvertNetworkToL1:
return "ConvertNetworkToL1Tx"
default:
return "unknown"
}
}
// zapNativeAdmissionGate wraps a TxVerifier and refuses admission for
// tx kinds whose zap_native executor body is not yet implemented. The
// wrapper preserves the underlying verifier's behavior for every other
// kind — single-purpose gate, composable, no side effects on the
// pass-through path.
//
// Construction: NewZapNativeAdmissionGate. The constructor is the only
// public surface; the wrapped verifier is hidden by implementation
// type to keep callers from accidentally bypassing the gate.
type zapNativeAdmissionGate struct {
inner TxVerifier
}
// VerifyTx fires the R7V5 gate FIRST, then delegates to the inner
// verifier when the tx is admissible. Order matters: the gate must
// fire BEFORE any state-machine execution so the executor never sees
// a not-yet-implemented kind.
func (g *zapNativeAdmissionGate) VerifyTx(tx *txs.Tx) error {
// Path 1 — legacy struct dispatch (today's hot path).
if isLegacyTxKindNotYetExecutable(tx) {
return fmt.Errorf(
"legacy tx type %T: %w",
tx.Unsigned, ErrZapNativeNotYetExecutable,
)
}
// Path 2 — zap_native wire bytes (future-proof). Only inspect the
// signed bytes when they exist; some test paths construct a Tx
// without calling Initialize.
if tx != nil {
if signedBytes := tx.Bytes(); len(signedBytes) >= 4 {
if kind := zapNativeWireKindNotYetExecutable(signedBytes); kind != zap_native.TxKindReserved {
return fmt.Errorf(
"zap_native wire kind %s (%d): %w",
zapNativeKindName(kind), kind, ErrZapNativeNotYetExecutable,
)
}
}
}
return g.inner.VerifyTx(tx)
}
// NewZapNativeAdmissionGate wraps a TxVerifier with the R7V5 gate. The
// wrapped verifier is consulted only for txs the gate admits.
//
// USAGE: pass the gate-wrapped TxVerifier into network.New so every
// inbound tx routes through the gate first. The bare TxVerifier should
// never be installed into a production Network — the gate must be
// the outermost layer at every admission boundary.
func NewZapNativeAdmissionGate(inner TxVerifier) TxVerifier {
return &zapNativeAdmissionGate{inner: inner}
}
@@ -0,0 +1,248 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package network
import (
"errors"
"testing"
"github.com/luxfi/ids"
"github.com/luxfi/node/vms/platformvm/txs"
"github.com/luxfi/node/vms/platformvm/txs/zap_native"
)
// LP-023 Red round 7 R7V5 — admission-gate tests.
//
// Threat model: an adversary submits a tx whose kind has no executor
// body yet. Without the gate, the executor would return
// "not yet implemented" mid-block — by which time the tx has already
// been gossiped, sat in the mempool, and consumed RAM. The gate
// REFUSES at admission so the tx never enters the mempool at all.
// errSentinel is the inner-verifier sentinel used to confirm the gate
// delegates to the wrapped verifier when admission is allowed.
var errSentinel = errors.New("inner verifier hit")
// passThroughVerifier returns errSentinel for every tx — used to assert
// that the gate ALWAYS calls into the inner verifier for non-gated kinds.
type passThroughVerifier struct{}
func (passThroughVerifier) VerifyTx(*txs.Tx) error { return errSentinel }
// blockEverythingVerifier returns errSentinel for every tx — used as a
// canary to confirm gated kinds never reach the inner verifier.
type blockEverythingVerifier struct{}
func (blockEverythingVerifier) VerifyTx(*txs.Tx) error { return errSentinel }
// TestZapNativeAdmissionGate_RejectsCreateSovereignL1Tx pins R7V5: the
// gate must refuse a legacy *txs.CreateSovereignL1Tx with
// ErrZapNativeNotYetExecutable, BEFORE the inner verifier sees the tx.
// The inner verifier here returns errSentinel for everything — if it
// fires we'd see errSentinel, not ErrZapNativeNotYetExecutable.
func TestZapNativeAdmissionGate_RejectsCreateSovereignL1Tx(t *testing.T) {
gate := NewZapNativeAdmissionGate(blockEverythingVerifier{})
tx := &txs.Tx{Unsigned: &txs.CreateSovereignL1Tx{}}
err := gate.VerifyTx(tx)
if !errors.Is(err, ErrZapNativeNotYetExecutable) {
t.Fatalf(
"gate.VerifyTx(CreateSovereignL1Tx) = %v, want ErrZapNativeNotYetExecutable",
err,
)
}
if errors.Is(err, errSentinel) {
t.Fatalf("gate let CreateSovereignL1Tx reach the inner verifier: %v", err)
}
}
// TestZapNativeAdmissionGate_PassThroughLegacyTypes pins the brief:
// "Legacy txs.CreateSubnetTx / txs.RegisterL1ValidatorTx (the working
// ones) still pass through". For txs.BaseTx (working executor) and
// txs.RegisterL1ValidatorTx + txs.ConvertNetworkToL1Tx (legacy
// executors at line 639/761), the gate must NOT fire; the inner
// verifier MUST see the tx (we assert via errSentinel).
func TestZapNativeAdmissionGate_PassThroughLegacyTypes(t *testing.T) {
gate := NewZapNativeAdmissionGate(passThroughVerifier{})
cases := []struct {
name string
tx *txs.Tx
}{
{"BaseTx", &txs.Tx{Unsigned: &txs.BaseTx{}}},
{"RegisterL1ValidatorTx", &txs.Tx{Unsigned: &txs.RegisterL1ValidatorTx{}}},
{"ConvertNetworkToL1Tx", &txs.Tx{Unsigned: &txs.ConvertNetworkToL1Tx{}}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := gate.VerifyTx(tc.tx)
if !errors.Is(err, errSentinel) {
t.Fatalf(
"gate.VerifyTx(%s) = %v, want errSentinel (gate must pass to inner)",
tc.name, err,
)
}
if errors.Is(err, ErrZapNativeNotYetExecutable) {
t.Fatalf(
"gate FALSE-POSITIVE on %s: returned ErrZapNativeNotYetExecutable for a kind with a live executor",
tc.name,
)
}
})
}
}
// TestZapNativeAdmissionGate_RejectsZapWireCreateSovereignL1 exercises
// the future-proof half: a wire buffer that PARSES as zap_native.
// CreateSovereignL1Tx (TxKind=23) must be refused even if the legacy
// Unsigned struct field is something innocuous. The gate inspects the
// signed bytes via zap_native.IsZAPBytes + Wrap*Tx; the kind discriminator
// is the truth source.
func TestZapNativeAdmissionGate_RejectsZapWireCreateSovereignL1(t *testing.T) {
stub := zap_native.OwnerStub{Threshold: 1, Locktime: 0, Address: ids.ShortID{0x42}}
zapTx := zap_native.NewCreateSovereignL1Tx(zap_native.CreateSovereignL1TxInput{
NetworkID: 1,
Owner: stub,
Validators: []zap_native.ValidatorsListEntry{newWireFriendlyValidator(t)},
Chains: []zap_native.ChainsListEntry{
{Name: []byte("evm"), VMID: ids.ID{0xed, 0xed}, FxIDs: []ids.ID{{0x01}}, GenesisData: []byte("g")},
},
})
wireBytes := zapTx.Bytes()
if !zap_native.IsZAPBytes(wireBytes) {
t.Fatalf("baseline: expected wire bytes to be ZAP-magic, got prefix=%q", string(wireBytes[:4]))
}
tx := &txs.Tx{Unsigned: &txs.BaseTx{}} // innocuous legacy struct
tx.SetBytes(wireBytes, wireBytes)
gate := NewZapNativeAdmissionGate(blockEverythingVerifier{})
err := gate.VerifyTx(tx)
if !errors.Is(err, ErrZapNativeNotYetExecutable) {
t.Fatalf(
"gate.VerifyTx(zap-wire CreateSovereignL1) = %v, want ErrZapNativeNotYetExecutable",
err,
)
}
}
// TestZapNativeAdmissionGate_RejectsZapWireRegisterL1Validator exercises
// the wire-kind=7 (RegisterL1Validator) gate path. Even though the
// legacy struct executor is live, the zap_native wire path is still
// gated until the wire-aware admission flow lands.
func TestZapNativeAdmissionGate_RejectsZapWireRegisterL1Validator(t *testing.T) {
zapTx := zap_native.NewRegisterL1ValidatorTx(
ids.ID{0xAA},
[zap_native.BLSPubKeySize]byte{},
[zap_native.BLSPoPSize]byte{},
1_900_000_000,
ids.ID{0xBB},
)
wireBytes := zapTx.Bytes()
if !zap_native.IsZAPBytes(wireBytes) {
t.Fatalf("baseline: expected wire bytes to be ZAP-magic, got prefix=%q", string(wireBytes[:4]))
}
tx := &txs.Tx{Unsigned: &txs.BaseTx{}}
tx.SetBytes(wireBytes, wireBytes)
gate := NewZapNativeAdmissionGate(blockEverythingVerifier{})
err := gate.VerifyTx(tx)
if !errors.Is(err, ErrZapNativeNotYetExecutable) {
t.Fatalf(
"gate.VerifyTx(zap-wire RegisterL1Validator) = %v, want ErrZapNativeNotYetExecutable",
err,
)
}
}
// TestZapNativeAdmissionGate_RejectsZapWireConvertNetworkToL1 exercises
// the wire-kind=22 (ConvertNetworkToL1) gate path.
func TestZapNativeAdmissionGate_RejectsZapWireConvertNetworkToL1(t *testing.T) {
zapTx := zap_native.NewConvertNetworkToL1Tx(zap_native.ConvertNetworkToL1TxInput{
NetworkID: 1,
BlockchainID: ids.ID{0x11},
Chain: ids.ID{0x22},
ManagerChainID: ids.ID{0x33},
Address: []byte("0x" + "00"),
Validators: []zap_native.ValidatorsListEntry{newWireFriendlyValidator(t)},
})
wireBytes := zapTx.Bytes()
if !zap_native.IsZAPBytes(wireBytes) {
t.Fatalf("baseline: expected wire bytes to be ZAP-magic, got prefix=%q", string(wireBytes[:4]))
}
tx := &txs.Tx{Unsigned: &txs.BaseTx{}}
tx.SetBytes(wireBytes, wireBytes)
gate := NewZapNativeAdmissionGate(blockEverythingVerifier{})
err := gate.VerifyTx(tx)
if !errors.Is(err, ErrZapNativeNotYetExecutable) {
t.Fatalf(
"gate.VerifyTx(zap-wire ConvertNetworkToL1) = %v, want ErrZapNativeNotYetExecutable",
err,
)
}
}
// TestZapNativeAdmissionGate_PassThroughNonZapBytes pins the gate's
// non-interference contract: a tx whose Bytes() are NOT ZAP-magic
// (e.g. legacy linearcodec-encoded) and whose Unsigned is a working
// executor type MUST be delegated to the inner verifier unmodified.
func TestZapNativeAdmissionGate_PassThroughNonZapBytes(t *testing.T) {
tx := &txs.Tx{Unsigned: &txs.BaseTx{}}
// Non-ZAP wire bytes — anything that doesn't start with "ZAP\x00".
tx.SetBytes([]byte{0x00, 0x00, 0x00, 0x01, 0xDE, 0xAD, 0xBE, 0xEF}, []byte{0x00, 0x00, 0x00, 0x01, 0xDE, 0xAD, 0xBE, 0xEF})
gate := NewZapNativeAdmissionGate(passThroughVerifier{})
err := gate.VerifyTx(tx)
if !errors.Is(err, errSentinel) {
t.Fatalf("gate.VerifyTx(non-ZAP) = %v, want errSentinel (gate must pass to inner)", err)
}
}
// TestZapNativeAdmissionGate_NilSafety confirms the gate handles
// edge-case inputs (nil tx, nil Unsigned, empty Bytes) without
// panicking. Defense-in-depth: the gate is on the hot path, so
// it MUST be panic-free on every input.
func TestZapNativeAdmissionGate_NilSafety(t *testing.T) {
gate := NewZapNativeAdmissionGate(passThroughVerifier{})
t.Run("nil tx", func(t *testing.T) {
// Inner verifier (passThroughVerifier) returns errSentinel on
// any input including nil; the gate must NOT panic on the way.
err := gate.VerifyTx(nil)
if !errors.Is(err, errSentinel) {
t.Fatalf("gate.VerifyTx(nil) = %v, want errSentinel", err)
}
})
t.Run("nil Unsigned", func(t *testing.T) {
err := gate.VerifyTx(&txs.Tx{Unsigned: nil})
if !errors.Is(err, errSentinel) {
t.Fatalf("gate.VerifyTx(nilUnsigned) = %v, want errSentinel", err)
}
})
t.Run("empty bytes", func(t *testing.T) {
tx := &txs.Tx{Unsigned: &txs.BaseTx{}}
tx.SetBytes(nil, nil)
err := gate.VerifyTx(tx)
if !errors.Is(err, errSentinel) {
t.Fatalf("gate.VerifyTx(emptyBytes) = %v, want errSentinel", err)
}
})
}
// newWireFriendlyValidator returns a ValidatorsListEntry whose
// constructor-time BLS fields are present (even if zeroed). The R7V5
// admission gate does NOT verify BLS PoP — that's R7V7 / Verify()'s
// job — so this minimal entry is sufficient for the gate test.
func newWireFriendlyValidator(t *testing.T) zap_native.ValidatorsListEntry {
t.Helper()
return zap_native.ValidatorsListEntry{
NodeID: ids.NodeID{0x77},
Weight: 1_000_000,
BLSPubKey: [zap_native.BLSPubKeySize]byte{},
BLSPoP: [zap_native.BLSPoPSize]byte{},
RegistrationExpiry: 1_900_000_000,
}
}
+101
View File
@@ -43,3 +43,104 @@ func TestAuditGate_AddressListNoProductionConsumers(t *testing.T) {
)
}
}
// TestAuditGate_ChainsListEmbeddersCallMustVerify mirrors the
// chainslist-verify-gate workflow job. Every tx type that EMBEDS a
// ChainsList (returns it from an accessor and exposes it through a
// per-tx Verify() body) MUST call .MustVerify() inside that Verify().
//
// LP-023 R7V8: the receiver-name rename Verify → MustVerify makes the
// gate grep-able from CI. The previous Verify() name collided with
// the tx-level Verify() convention and invited the reader to assume
// "the tx Verify already covered this" — wrong, because the tx-level
// Verify is responsible for orchestrating the per-field gates, not
// the list-level walk.
//
// Heuristic: enumerate tx types that own a ChainsList-returning Chains()
// accessor. For each such tx type T, confirm that .MustVerify() is
// called inside ANY function with a receiver of (T) or (T )
// (the per-tx Verify body, which by convention lives in tx_verify.go).
// The check is whole-package because tx_verify.go centralizes the
// Verify methods for the package — embedder file (type definition)
// and Verify file (gate body) are decoupled by design.
//
// Allowlist mechanism: a file may legitimately skip the call (e.g.
// the type definition itself, or audit_test.go which would self-match).
// Document any exception inline.
//
// Local repro: `cd vms/platformvm/txs/zap_native && go test -run
// TestAuditGate_ChainsListEmbeddersCallMustVerify -v`
func TestAuditGate_ChainsListEmbeddersCallMustVerify(t *testing.T) {
// Step 1: enumerate tx types that EMBED ChainsList. A tx type T is
// an embedder if it has an accessor like `func (t T) Chains()
// ChainsListView` defined in a production file.
out, err := exec.Command("sh", "-c",
`grep -rEoh 'func \(t [A-Z][A-Za-z0-9_]+\) Chains\(\) (ChainsList|ChainsListView|BoundChainsList)' `+
`--include='*.go' . `+
`| grep -vE '(_test\.go)' `+
`| sed -E 's/^func \(t ([A-Za-z0-9_]+)\) Chains.*/\1/' `+
`| sort -u || true`,
).Output()
if err != nil {
t.Fatalf("embedder-type grep exec failed: %v", err)
}
embedderTypes := strings.Fields(strings.TrimSpace(string(out)))
if len(embedderTypes) == 0 {
t.Log("Audit clean: zero ChainsList embedder tx types")
return
}
// Step 2: for each embedder type T, scan the package for a
// MustVerify() call inside any method body whose receiver
// includes T. The simplest grep: look for the package-wide
// presence of `.MustVerify(` AND a method `func (...T) Verify()`.
// Since this package centralizes Verify in tx_verify.go, we just
// confirm that EVERY embedder T appears in a function-receiver
// line within tx_verify.go AND that file contains `.MustVerify(`.
var offenders []string
for _, T := range embedderTypes {
// (a) confirm a Verify() method for T exists in tx_verify.go.
methodRe := `func \([a-z]+ ` + T + `\) Verify\(\)`
hits, err := exec.Command("sh", "-c",
`grep -E '`+methodRe+`' tx_verify.go || true`,
).Output()
if err != nil {
t.Fatalf("method grep exec failed for %s: %v", T, err)
}
hasVerify := strings.TrimSpace(string(hits)) != ""
// (b) confirm the same file calls .MustVerify().
mvOut, err := exec.Command("sh", "-c",
`grep -l '\.MustVerify(' tx_verify.go || true`,
).Output()
if err != nil {
t.Fatalf("MustVerify grep exec failed for %s: %v", T, err)
}
hasMV := strings.TrimSpace(string(mvOut)) != ""
if hasVerify && !hasMV {
offenders = append(offenders,
T+" (Verify() in tx_verify.go does not call .MustVerify())")
}
// If the embedder type has NO Verify() method at all, it can't
// have skipped MustVerify by definition. The gate is a no-op
// for embedders that don't expose Verify() (e.g. future tx
// types that punt the gate to a follow-up).
}
if len(offenders) > 0 {
t.Fatalf(
"ChainsList embedder tx type(s) missing .MustVerify() call.\n"+
"Every tx type T that has Chains() accessor AND a Verify()\n"+
"method MUST call list.MustVerify() inside that Verify()\n"+
"to enforce the FxIDsLen + reserved-bytes invariants\n"+
"(R6-4 / R6V5 / R7V8).\n"+
"\n"+
"Either wire the gate or document why it's safe to skip\n"+
"inline with a clear comment.\n"+
"\n"+
"Offenders:\n%s",
strings.Join(offenders, "\n"),
)
}
}
+14 -3
View File
@@ -41,7 +41,7 @@ const (
OffsetChainEntry_FxIDsLen = 44
OffsetChainEntry_GenesisDataRel = 48
OffsetChainEntry_GenesisDataLen = 52
OffsetChainEntry_Reserved = 56 // [56..64) — must be zero, gated by ChainsListView.Verify (R6-4)
OffsetChainEntry_Reserved = 56 // [56..64) — must be zero, gated by ChainsListView.MustVerify (R6-4 + R7V8)
SizeChainEntry = 64
// FxIDSize is the wire size of one chain ID inside the FxIDs blob.
@@ -288,7 +288,7 @@ func NewChainsListView(parent zap.Object, fieldOffset int) ChainsListView {
return ChainsListView{list: parent.ListStride(fieldOffset, SizeChainEntry)}
}
// Verify walks every entry in the list and asserts:
// MustVerify walks every entry in the list and asserts:
// - FxIDsLen is an exact multiple of FxIDSize (R6V5).
// - RESERVED bytes at offsets [56..64) within each entry are all zero
// (R6-4 / batch 5 v3.5). The writer pads them to zero; a parser that
@@ -306,7 +306,18 @@ func NewChainsListView(parent zap.Object, fieldOffset int) ChainsListView {
// when the list is empty or every entry passes — empty-list rejection
// is the caller's gate (CreateSovereignL1Tx.Verify enforces non-empty
// via ErrZeroChains before invoking this).
func (l ChainsListView) Verify() error {
//
// LP-023 Red round 7 R7V8 renamed Verify → MustVerify. The receiver-
// name pattern makes "I forgot to call this" a grep-able regression:
// the audit gate (audit_test.go +
// .github/workflows/zap-audit.yml chainslist-verify-gate) walks every
// file that embeds a ChainsList in a tx type and confirms it calls
// .MustVerify() somewhere inside its Verify() body. The previous
// Verify() name collided with the tx-level Verify() convention and
// invited the reader to assume "the tx Verify already covered this"
// — wrong, because the tx-level Verify is responsible for orchestrating
// the per-field gates, not the list-level walk.
func (l ChainsListView) MustVerify() error {
n := l.Len()
for i := 0; i < n; i++ {
entry := l.At(i)
@@ -208,11 +208,16 @@ func TestChainsList_Verify_RejectsBadFxIDsLen(t *testing.T) {
}
}
// TestChainsListView_Verify_StandaloneEntries exercises the helper on a
// hand-constructed ChainsListView so Verify is callable independently
// TestChainsListView_MustVerify_StandaloneEntries exercises the helper on
// a hand-constructed ChainsListView so MustVerify is callable independently
// of CreateSovereignL1Tx. Defense-in-depth: any future tx that embeds
// ChainsList can call Verify() directly.
func TestChainsListView_Verify_StandaloneEntries(t *testing.T) {
// ChainsList must call MustVerify() inside its own Verify() body — the
// audit gate (audit_test.go + .github/workflows/zap-audit.yml
// chainslist-verify-gate) enforces this at CI.
//
// LP-023 R7V8: renamed Verify → MustVerify so the consumer-side gate
// is grep-able from the CI workflow.
func TestChainsListView_MustVerify_StandaloneEntries(t *testing.T) {
// Good entry: FxIDsLen = 2*32 = 64.
entries := []ChainsListEntry{
{Name: []byte("a"), VMID: ids.ID{0x01}, FxIDs: []ids.ID{{0xff}, {0xee}}},
@@ -224,8 +229,8 @@ func TestChainsListView_Verify_StandaloneEntries(t *testing.T) {
Validators: []ValidatorsListEntry{makeValidVerifyingValidator(t)},
Chains: entries,
})
if err := tx.Chains().Verify(); err != nil {
t.Fatalf("ChainsListView.Verify = %v, want nil", err)
if err := tx.Chains().MustVerify(); err != nil {
t.Fatalf("ChainsListView.MustVerify = %v, want nil", err)
}
}
@@ -0,0 +1,349 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package zap_native
import (
"errors"
"testing"
"github.com/luxfi/crypto/bls"
"github.com/luxfi/crypto/bls/signer/localsigner"
"github.com/luxfi/ids"
)
// LP-023 Red round 7 R7V7 — Verify() tests for RegisterL1ValidatorTx +
// ConvertNetworkToL1Tx.
//
// Both tx types carry BLS PoP fields that the wire layer cannot prove
// pair-correct. Blue's 8-tx Verify() list excluded them; the gate fires
// even though Path A (R7V5 admission gate) refuses these kinds at the
// mempool boundary today. Defense-in-depth: when the executor lands
// (batch 6+) and the admission gate is lifted, the Verify() path is
// already canonical.
// makeKnownGoodBLS mints a real BLS keypair and returns the
// (pkBytes, popBytes) pair that bls.VerifyProofOfPossession accepts.
// Tests use this when they want Verify() to succeed on the BLS gate.
func makeKnownGoodBLS(t *testing.T) (pkBytes [BLSPubKeySize]byte, popBytes [BLSPoPSize]byte) {
t.Helper()
sk, err := bls.NewSecretKey()
if err != nil {
t.Fatalf("bls.NewSecretKey: %v", err)
}
ls, err := localsigner.FromBytes(bls.SecretKeyToBytes(sk))
if err != nil {
t.Fatalf("localsigner.FromBytes: %v", err)
}
pk := ls.PublicKey()
pkRaw := bls.PublicKeyToCompressedBytes(pk)
sig, err := ls.SignProofOfPossession(pkRaw)
if err != nil {
t.Fatalf("SignProofOfPossession: %v", err)
}
sigRaw := bls.SignatureToBytes(sig)
copy(pkBytes[:], pkRaw)
copy(popBytes[:], sigRaw)
return pkBytes, popBytes
}
// makeMismatchedBLS mints two independent BLS keypairs and crosses them
// — returns pk from keypair 1 paired with a PoP signed by keypair 2.
// The pairing check MUST fail.
func makeMismatchedBLS(t *testing.T) (pkBytes [BLSPubKeySize]byte, popBytes [BLSPoPSize]byte) {
t.Helper()
pk1, _ := makeKnownGoodBLS(t)
_, pop2 := makeKnownGoodBLS(t)
return pk1, pop2
}
// TestRegisterL1ValidatorTx_Verify_RejectsBadBLSPoP pins R7V7: a
// RegisterL1ValidatorTx with a BLS PoP that doesn't pair with the
// BLS pubkey must be refused by Verify().
func TestRegisterL1ValidatorTx_Verify_RejectsBadBLSPoP(t *testing.T) {
pk, badPop := makeMismatchedBLS(t)
tx := NewRegisterL1ValidatorTx(
ids.ID{0xAA},
pk,
badPop, // adversary substituted a PoP from a different key
1_900_000_000,
ids.ID{0xBB},
)
err := tx.Verify()
if !errors.Is(err, ErrBadBLSPoP) {
t.Fatalf("Verify(mismatched BLS) = %v, want ErrBadBLSPoP", err)
}
}
// TestRegisterL1ValidatorTx_Verify_RejectsMalformedBLSPubKey pins R7V7:
// a BLS pubkey that fails PublicKeyFromCompressedBytes (e.g. all zero
// or non-canonical encoding) must be refused by Verify().
func TestRegisterL1ValidatorTx_Verify_RejectsMalformedBLSPubKey(t *testing.T) {
var pk [BLSPubKeySize]byte
// All-zero pubkey is not a valid compressed-G1 encoding.
_, validPop := makeKnownGoodBLS(t)
tx := NewRegisterL1ValidatorTx(
ids.ID{0xAA},
pk,
validPop,
1_900_000_000,
ids.ID{0xBB},
)
err := tx.Verify()
if !errors.Is(err, ErrBadBLSPoP) {
t.Fatalf("Verify(malformed pk) = %v, want ErrBadBLSPoP", err)
}
}
// TestRegisterL1ValidatorTx_Verify_AcceptsValidPoP pins R7V7 positive
// path: a well-formed pubkey + PoP pair MUST pass Verify().
func TestRegisterL1ValidatorTx_Verify_AcceptsValidPoP(t *testing.T) {
pk, pop := makeKnownGoodBLS(t)
tx := NewRegisterL1ValidatorTx(
ids.ID{0xAA},
pk,
pop,
1_900_000_000,
ids.ID{0xBB},
)
if err := tx.Verify(); err != nil {
t.Fatalf("Verify(valid pk+pop) = %v, want nil", err)
}
}
// TestRegisterL1ValidatorTx_Verify_RejectsZeroExpiry pins R7V7: an
// Expiry of zero is never a legitimate registration window. Full
// timestamp-vs-now() check lives in the executor; this is the
// syntactic floor.
func TestRegisterL1ValidatorTx_Verify_RejectsZeroExpiry(t *testing.T) {
pk, pop := makeKnownGoodBLS(t)
tx := NewRegisterL1ValidatorTx(
ids.ID{0xAA},
pk,
pop,
0, // adversarial zero expiry
ids.ID{0xBB},
)
err := tx.Verify()
if !errors.Is(err, ErrZeroExpiry) {
t.Fatalf("Verify(zero expiry) = %v, want ErrZeroExpiry", err)
}
}
// TestRegisterL1ValidatorTx_Verify_AdversarialWireBuffer pins R7V7
// against the strongest threat model: adversary controls the wire byte
// stream. We construct a legitimate tx then patch the Expiry bytes to
// zero in the wire buffer and re-wrap. Verify() must catch it.
func TestRegisterL1ValidatorTx_Verify_AdversarialWireBuffer(t *testing.T) {
pk, pop := makeKnownGoodBLS(t)
tx := NewRegisterL1ValidatorTx(
ids.ID{0xAA},
pk,
pop,
1_900_000_000,
ids.ID{0xBB},
)
if err := tx.Verify(); err != nil {
t.Fatalf("baseline Verify = %v, want nil", err)
}
buf := tx.Bytes()
// Locate Expiry by its known value. The constructor emits the
// fixed section at the root object; Expiry is at
// OffsetRegisterL1ValidatorTx_Expiry within that object. We can't
// know the exact root-object byte offset in the parent header
// without re-parsing — so we search for the known little-endian
// encoding of 1_900_000_000 (0x713FB300 in hex) and zero its bytes.
//
// 1_900_000_000 decimal = 0x713FB300 (LE bytes: 00 B3 3F 71 00 00 00 00).
const target0 = 0x00
const target1 = 0xB3
const target2 = 0x3F
const target3 = 0x71
found := false
for i := 0; i+8 <= len(buf); i++ {
if buf[i] == target0 && buf[i+1] == target1 &&
buf[i+2] == target2 && buf[i+3] == target3 &&
buf[i+4] == 0 && buf[i+5] == 0 && buf[i+6] == 0 && buf[i+7] == 0 {
// Zero the 8 bytes in place.
for j := 0; j < 8; j++ {
buf[i+j] = 0
}
found = true
break
}
}
if !found {
t.Fatalf("could not locate Expiry bytes in wire buffer")
}
tampered, err := WrapRegisterL1ValidatorTx(buf)
if err != nil {
t.Fatalf("WrapRegisterL1ValidatorTx(tampered) = %v, want nil", err)
}
if got := tampered.Expiry(); got != 0 {
t.Fatalf("tampered Expiry = %d, want 0", got)
}
if err := tampered.Verify(); !errors.Is(err, ErrZeroExpiry) {
t.Fatalf("Verify(tampered zero-expiry) = %v, want ErrZeroExpiry", err)
}
}
// TestConvertNetworkToL1Tx_Verify_RejectsZeroValidators pins R7V7:
// an empty Validators sub-list breaks consensus on the new L1 (no
// quorum can ever form). Verify must reject at the wire boundary.
func TestConvertNetworkToL1Tx_Verify_RejectsZeroValidators(t *testing.T) {
tx := NewConvertNetworkToL1Tx(ConvertNetworkToL1TxInput{
NetworkID: 1,
BlockchainID: ids.ID{0x11},
Chain: ids.ID{0x22},
ManagerChainID: ids.ID{0x33},
Address: []byte("0x00"),
Validators: nil, // adversarial empty set
})
err := tx.Verify()
if !errors.Is(err, ErrZeroValidators) {
t.Fatalf("Verify(zero validators) = %v, want ErrZeroValidators", err)
}
}
// TestConvertNetworkToL1Tx_Verify_RejectsZeroWeight pins R7V7: a
// per-validator Weight of zero skews quorum (filler entries that pad
// the count but contribute nothing to the threshold).
func TestConvertNetworkToL1Tx_Verify_RejectsZeroWeight(t *testing.T) {
pk, pop := makeKnownGoodBLS(t)
tx := NewConvertNetworkToL1Tx(ConvertNetworkToL1TxInput{
NetworkID: 1,
BlockchainID: ids.ID{0x11},
Chain: ids.ID{0x22},
ManagerChainID: ids.ID{0x33},
Address: []byte("0x00"),
Validators: []ValidatorsListEntry{
{
NodeID: ids.NodeID{0x77},
Weight: 0, // adversarial zero weight
BLSPubKey: pk,
BLSPoP: pop,
RegistrationExpiry: 1_900_000_000,
},
},
})
err := tx.Verify()
if !errors.Is(err, ErrValidatorWeightZero) {
t.Fatalf("Verify(zero weight) = %v, want ErrValidatorWeightZero", err)
}
}
// TestConvertNetworkToL1Tx_Verify_RejectsBadBLSPoP pins R7V7: a
// per-validator BLS PoP that doesn't pair with the BLS pubkey is the
// authority-substitution primitive that the gate must block.
func TestConvertNetworkToL1Tx_Verify_RejectsBadBLSPoP(t *testing.T) {
pk, badPop := makeMismatchedBLS(t)
tx := NewConvertNetworkToL1Tx(ConvertNetworkToL1TxInput{
NetworkID: 1,
BlockchainID: ids.ID{0x11},
Chain: ids.ID{0x22},
ManagerChainID: ids.ID{0x33},
Address: []byte("0x00"),
Validators: []ValidatorsListEntry{
{
NodeID: ids.NodeID{0x77},
Weight: 1_000_000,
BLSPubKey: pk,
BLSPoP: badPop,
RegistrationExpiry: 1_900_000_000,
},
},
})
err := tx.Verify()
if !errors.Is(err, ErrBadBLSPoP) {
t.Fatalf("Verify(mismatched BLS) = %v, want ErrBadBLSPoP", err)
}
}
// TestConvertNetworkToL1Tx_Verify_AcceptsValidValidators pins R7V7
// positive path: a well-formed validators set MUST pass Verify().
func TestConvertNetworkToL1Tx_Verify_AcceptsValidValidators(t *testing.T) {
pk, pop := makeKnownGoodBLS(t)
tx := NewConvertNetworkToL1Tx(ConvertNetworkToL1TxInput{
NetworkID: 1,
BlockchainID: ids.ID{0x11},
Chain: ids.ID{0x22},
ManagerChainID: ids.ID{0x33},
Address: []byte("0x00"),
Validators: []ValidatorsListEntry{
{
NodeID: ids.NodeID{0x77},
Weight: 1_000_000,
BLSPubKey: pk,
BLSPoP: pop,
RegistrationExpiry: 1_900_000_000,
},
},
})
if err := tx.Verify(); err != nil {
t.Fatalf("Verify(valid) = %v, want nil", err)
}
}
// TestConvertNetworkToL1Tx_Verify_AdversarialWireBuffer pins R7V7
// against the strongest threat model: adversary controls the wire
// byte stream. We construct a legitimate tx then zero the first
// validator's Weight bytes in the wire buffer and re-wrap. Verify()
// must catch it.
func TestConvertNetworkToL1Tx_Verify_AdversarialWireBuffer(t *testing.T) {
pk, pop := makeKnownGoodBLS(t)
const goodWeight = uint64(1_000_000)
tx := NewConvertNetworkToL1Tx(ConvertNetworkToL1TxInput{
NetworkID: 1,
BlockchainID: ids.ID{0x11},
Chain: ids.ID{0x22},
ManagerChainID: ids.ID{0x33},
Address: []byte("0x00"),
Validators: []ValidatorsListEntry{
{
NodeID: ids.NodeID{0x77},
Weight: goodWeight,
BLSPubKey: pk,
BLSPoP: pop,
RegistrationExpiry: 1_900_000_000,
},
},
})
if err := tx.Verify(); err != nil {
t.Fatalf("baseline Verify = %v, want nil", err)
}
buf := tx.Bytes()
// 1_000_000 decimal = 0xF4240 → LE bytes: 40 42 0F 00 00 00 00 00.
// Search for the canonical 8-byte LE encoding and zero it.
const target0 = 0x40
const target1 = 0x42
const target2 = 0x0F
found := false
for i := 0; i+8 <= len(buf); i++ {
if buf[i] == target0 && buf[i+1] == target1 &&
buf[i+2] == target2 && buf[i+3] == 0 &&
buf[i+4] == 0 && buf[i+5] == 0 && buf[i+6] == 0 && buf[i+7] == 0 {
for j := 0; j < 8; j++ {
buf[i+j] = 0
}
found = true
break
}
}
if !found {
t.Fatalf("could not locate Weight bytes in wire buffer")
}
tampered, err := WrapConvertNetworkToL1Tx(buf)
if err != nil {
t.Fatalf("WrapConvertNetworkToL1Tx(tampered) = %v, want nil", err)
}
if got := tampered.Validators().At(0).Weight(); got != 0 {
t.Fatalf("tampered Weight = %d, want 0", got)
}
if err := tampered.Verify(); !errors.Is(err, ErrValidatorWeightZero) {
t.Fatalf("Verify(tampered zero-weight) = %v, want ErrValidatorWeightZero", err)
}
}
+127 -1
View File
@@ -243,11 +243,15 @@ func (t CreateSovereignL1Tx) Verify() error {
// R6V4: non-empty chains.
// R6V5: every entry's FxIDsLen must be an exact multiple of FxIDSize.
// R7V8: ChainsListView.Verify was renamed MustVerify so the gate is
// CALLED explicitly by every embedder — the receiver name makes
// "I forgot to call this" a compile-time error in CI (audit_test +
// .github/workflows/zap-audit.yml chainslist-verify-gate).
chains := t.Chains()
if chains.Len() == 0 {
return fmt.Errorf("CreateSovereignL1Tx.Chains: %w", ErrZeroChains)
}
if err := chains.Verify(); err != nil {
if err := chains.MustVerify(); err != nil {
return fmt.Errorf("CreateSovereignL1Tx.Chains: %w", err)
}
return nil
@@ -270,3 +274,125 @@ func (t TransferChainOwnershipTx) Verify() error {
}
return nil
}
// ErrZeroExpiry is returned when a RegisterL1ValidatorTx carries Expiry == 0.
// Wire-layer Expiry is a unix timestamp; zero never represents a legitimate
// future registration window. Full timestamp-vs-now() gate lives in the
// executor (R7V7: SyntacticVerify is clock-independent here).
//
// LP-023 Red round 7 R7V7.
var ErrZeroExpiry = errors.New(
"zap_native: RegisterL1ValidatorTx.Expiry must be > 0; zero never represents a legitimate window",
)
// Verify pins R7V7 HIGH: a RegisterL1ValidatorTx must carry a verifiable
// BLS proof-of-possession, a non-zero Expiry, and (if non-zero) a
// well-formed RemainingBalanceOwnerID. The wire-decoded buffer geometry
// is canonical via parseAndCheckKind; this gate fires the semantic
// invariants the wire layer cannot infer.
//
// LP-023 Red round 7 R7V7 closes the gap where the wire carried
// BLS+Expiry+OwnerID fields but Blue's 8-tx Verify() list excluded
// RegisterL1ValidatorTx — defense-in-depth pairing with the mempool
// admission gate (R7V5) which refuses the tx at the network boundary
// today.
//
// Note: per-validator RegistrationExpiry > now() is intentionally NOT
// enforced here. SyntacticVerify is clock-independent (executor
// wall-clock lives in the staking handler); this file enforces only
// properties that are invariant under wire encoding. The zero-Expiry
// gate is a syntactic floor — wire-canonically a unix timestamp can
// never be zero in a legitimate registration.
func (t RegisterL1ValidatorTx) Verify() error {
// BLS PoP gate — same pairing the CreateSovereignL1Tx walk fires
// per-validator. Wire layer is opaque about pairing validity; this
// gate is the only place it fires before the executor commits the
// validator registration.
pkBytes := t.BLSPublicKey()
sigBytes := t.ProofOfPossession()
pk, err := bls.PublicKeyFromCompressedBytes(pkBytes[:])
if err != nil {
return fmt.Errorf(
"RegisterL1ValidatorTx.BLSPublicKey: %w (%v)", ErrBadBLSPoP, err,
)
}
sig, err := bls.SignatureFromBytes(sigBytes[:])
if err != nil {
return fmt.Errorf(
"RegisterL1ValidatorTx.ProofOfPossession: %w (%v)", ErrBadBLSPoP, err,
)
}
if !bls.VerifyProofOfPossession(pk, sig, pkBytes[:]) {
return fmt.Errorf(
"RegisterL1ValidatorTx.ProofOfPossession: %w", ErrBadBLSPoP,
)
}
// Zero-Expiry gate — full clock check lives in the executor.
if t.Expiry() == 0 {
return fmt.Errorf("RegisterL1ValidatorTx.Expiry: %w", ErrZeroExpiry)
}
// RemainingBalanceOwnerID is a v3 placeholder ids.ID. Treat as
// optional — zero ID is a legitimate "no remaining balance owner"
// encoding. Batch 3 replaces this with a full OutputOwners schema
// (threshold + AddressIDs list) at which point a proper
// SyntacticVerify lands; for now the wire layer's only invariant
// is that the field is 32 bytes (already enforced by the parser).
return nil
}
// Verify pins R7V7 HIGH for ConvertNetworkToL1Tx: the Validators sub-list
// must be non-empty, every validator's Weight must be > 0, and every
// validator's BLS proof-of-possession must verify. Same per-validator
// walk as CreateSovereignL1Tx — the two tx types share the
// ValidatorsList primitive, and any malformed entry in either is a
// quorum-skew or authority-substitution primitive.
//
// LP-023 Red round 7 R7V7 closes the gap where ConvertNetworkToL1Tx was
// excluded from Blue's 8-tx Verify() list. Defense-in-depth pairing with
// the mempool admission gate (R7V5).
//
// Note: the legacy txs.ConvertNetworkToL1Tx executor is already
// implemented (standard_tx_executor.go:639), but the zap_native wire
// path still needs the syntactic gate so when zap_native ConvertNetworkToL1
// finally wires into the codec, the executor admission boundary is
// covered defense-in-depth alongside the network-layer R7V5 gate.
func (t ConvertNetworkToL1Tx) Verify() error {
vals := t.Validators()
n := vals.Len()
if n == 0 {
return fmt.Errorf("ConvertNetworkToL1Tx.Validators: %w", ErrZeroValidators)
}
for i := 0; i < n; i++ {
rec := vals.At(i)
if rec.Weight() == 0 {
return fmt.Errorf(
"ConvertNetworkToL1Tx.Validators[%d].Weight: %w", i, ErrValidatorWeightZero,
)
}
pkBytes := rec.BLSPubKey()
sigBytes := rec.BLSPoP()
pk, err := bls.PublicKeyFromCompressedBytes(pkBytes)
if err != nil {
return fmt.Errorf(
"ConvertNetworkToL1Tx.Validators[%d].BLSPubKey: %w (%v)",
i, ErrBadBLSPoP, err,
)
}
sig, err := bls.SignatureFromBytes(sigBytes)
if err != nil {
return fmt.Errorf(
"ConvertNetworkToL1Tx.Validators[%d].BLSPoP: %w (%v)",
i, ErrBadBLSPoP, err,
)
}
if !bls.VerifyProofOfPossession(pk, sig, pkBytes) {
return fmt.Errorf(
"ConvertNetworkToL1Tx.Validators[%d].BLSPoP: %w",
i, ErrBadBLSPoP,
)
}
}
return nil
}