mirror of
https://github.com/luxfi/node.git
synced 2026-07-27 03:39:39 +00:00
#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:
@@ -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()"
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user