#189: LP-023 batch 5 v3 — wire Verify() gate for R6V4 (CreateSovereignL1Tx zero-validator/zero-chain CRITICAL) + R6V8 (TransferChainOwnershipTx HIGH) + R6V3 (BLS PoP verification HIGH) + R6V5 (FxIDs malformed length MEDIUM)

R6V4 (CRITICAL): CreateSovereignL1Tx.Verify now rejects zero-validator and
zero-chain wire buffers (both halt consensus at activation). Per-validator
Weight > 0 walk also fires here. RegistrationExpiry remains an executor
clock concern (deferred to staking handler, documented).

R6V8 (HIGH): TransferChainOwnershipTx had no Verify() despite carrying
Owner fields. Wired in: reconstructs OwnerStub from the (threshold,
locktime, address) tuple and calls SyntacticVerify. Tx count with
SyntacticVerify wired into 8 tx types (was 7).

R6V3 (HIGH): BLS PoP now verified at the SyntacticVerify boundary for
every initial validator. Wire-layer opaque 48B BLSPubKey + 96B BLSPoP
now pairing-checked via bls.VerifyProofOfPossession with new ErrBadBLSPoP.
Closes the zero-downstream-consumer gap Red grep found.

R6V5 (MEDIUM): ChainsListView.Verify walks entries and rejects any
FxIDsLen that is not a multiple of FxIDSize. Replaces the silent-nil
return path in BoundChainEntry.FxIDs with a typed error. Wired into
CreateSovereignL1Tx.Verify via ChainsListView.Verify().

Tests (all under go test -race):
  TestCreateSovereignL1Tx_Verify_RejectsZeroValidators
  TestCreateSovereignL1Tx_Verify_RejectsZeroChains
  TestCreateSovereignL1Tx_Verify_RejectsZeroWeight
  TestCreateSovereignL1Tx_Verify_RejectsBadBLSPoP
  TestCreateSovereignL1Tx_Verify_RejectsBadBLSPoP_MismatchedPubKey
  TestCreateSovereignL1Tx_Verify_AdversarialWireBuffer_ValidatorWeight
  TestChainsList_Verify_RejectsBadFxIDsLen (adversarial wire buffer)
  TestChainsListView_Verify_StandaloneEntries
  TestTransferChainOwnershipTx_Verify_RejectsZeroThreshold
  TestTransferChainOwnershipTx_Verify_RejectsThresholdAboveOne
  TestTransferChainOwnershipTx_Verify_AdversarialWireBuffer
  TestBLSSurfaceReachable

Updates TestVerify_AcceptsWellFormed/CreateSovereignL1 to supply a
properly-constructed validator (real BLS PoP) and a chain entry so the
new gates fire green on the legitimate path. Adds
TestVerify_AcceptsWellFormed/TransferChainOwnership subtest for R6V8.

New typed errors:
  ErrZeroValidators, ErrZeroChains, ErrValidatorWeightZero,
  ErrBadBLSPoP, ErrMalformedFxIDsLen.

Bench unchanged (Verify is on executor path, parse benches structurally
untouched). Full zap_native suite green under -race.

LP-023 batch 5 v3 closes Red round 6 V3/V4/V5/V8.
This commit is contained in:
Hanzo AI
2026-06-02 19:04:07 -07:00
parent 0c66ab55ae
commit d5c305d440
4 changed files with 649 additions and 5 deletions
@@ -4,6 +4,8 @@
package zap_native
import (
"fmt"
"github.com/luxfi/ids"
"github.com/luxfi/zap"
)
@@ -244,6 +246,31 @@ func NewChainsListView(parent zap.Object, fieldOffset int) ChainsListView {
return ChainsListView{list: parent.ListStride(fieldOffset, SizeChainEntry)}
}
// Verify walks every entry in the list and asserts that FxIDsLen is an
// exact multiple of FxIDSize. R6V5 closes the silent-nil path where
// BoundChainEntry.FxIDs returns nil for a malformed length, which a
// downstream consumer can mis-interpret as "no FxIDs allowed". The check
// is pure cursor inspection — no Bind required, no blob slicing. Wired
// into CreateSovereignL1Tx.Verify (and any future tx that embeds a
// ChainsList).
//
// Returns ErrMalformedFxIDsLen wrapped with the entry index on the first
// malformed entry. Returns nil when the list is empty or every entry's
// FxIDsLen passes — empty-list rejection is the caller's gate
// (CreateSovereignL1Tx.Verify enforces non-empty via ErrZeroChains
// before invoking this).
func (l ChainsListView) Verify() error {
n := l.Len()
for i := 0; i < n; i++ {
entry := l.At(i)
_, length := entry.FxIDsRange()
if length%FxIDSize != 0 {
return fmt.Errorf("ChainsList[%d].FxIDsLen=%d: %w", i, length, ErrMalformedFxIDsLen)
}
}
return nil
}
// ChainsListEntry is the constructor input for a ChainsList. The variable
// fields (Name, FxIDs, GenesisData) are concatenated into the shared blob
// arrays during write; entry header cursors point into them.
@@ -0,0 +1,409 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package zap_native
import (
"crypto/rand"
"errors"
"testing"
"github.com/luxfi/crypto/bls"
"github.com/luxfi/ids"
"github.com/luxfi/zap"
)
// LP-023 Red round 6 verify-gate tests.
//
// Adversarial threat model: the attacker controls the wire byte stream
// (not the constructor). Each test below constructs a CreateSovereignL1Tx
// (or TransferChainOwnershipTx) the legitimate way, then either:
// - omits a required field (zero validators / zero chains) via the
// constructor, OR
// - patches a field in the wire buffer post-encode to a malicious
// value and re-Wraps.
// The executor-side Verify() gate MUST catch it.
// TestCreateSovereignL1Tx_Verify_RejectsZeroValidators pins R6V4 critical:
// a zero-validator L1 cannot bootstrap consensus. Verify must reject at
// the wire boundary.
func TestCreateSovereignL1Tx_Verify_RejectsZeroValidators(t *testing.T) {
stub := OwnerStub{Threshold: 1, Locktime: 0, Address: ids.ShortID{0x42}}
tx := NewCreateSovereignL1Tx(CreateSovereignL1TxInput{
NetworkID: 1,
Owner: stub,
Validators: nil, // adversarial empty set
Chains: []ChainsListEntry{makeValidChainsEntry()},
})
err := tx.Verify()
if !errors.Is(err, ErrZeroValidators) {
t.Fatalf("Verify(zero validators) = %v, want ErrZeroValidators", err)
}
}
// TestCreateSovereignL1Tx_Verify_RejectsZeroChains pins R6V4 critical:
// a zero-chain L1 is malformed; Verify must reject.
func TestCreateSovereignL1Tx_Verify_RejectsZeroChains(t *testing.T) {
stub := OwnerStub{Threshold: 1, Locktime: 0, Address: ids.ShortID{0x42}}
tx := NewCreateSovereignL1Tx(CreateSovereignL1TxInput{
NetworkID: 1,
Owner: stub,
Validators: []ValidatorsListEntry{makeValidVerifyingValidator(t)},
Chains: nil, // adversarial empty list
})
err := tx.Verify()
if !errors.Is(err, ErrZeroChains) {
t.Fatalf("Verify(zero chains) = %v, want ErrZeroChains", err)
}
}
// TestCreateSovereignL1Tx_Verify_RejectsZeroWeight pins R6V4: a
// zero-weight validator skews quorum (it pads the validator count but
// contributes nothing to threshold), so reject. Construct two validators
// where the second has weight=0.
func TestCreateSovereignL1Tx_Verify_RejectsZeroWeight(t *testing.T) {
stub := OwnerStub{Threshold: 1, Locktime: 0, Address: ids.ShortID{0x42}}
good := makeValidVerifyingValidator(t)
bad := makeValidVerifyingValidator(t)
bad.Weight = 0 // adversarial
tx := NewCreateSovereignL1Tx(CreateSovereignL1TxInput{
NetworkID: 1,
Owner: stub,
Validators: []ValidatorsListEntry{good, bad},
Chains: []ChainsListEntry{makeValidChainsEntry()},
})
err := tx.Verify()
if !errors.Is(err, ErrValidatorWeightZero) {
t.Fatalf("Verify(zero-weight validator) = %v, want ErrValidatorWeightZero", err)
}
}
// TestCreateSovereignL1Tx_Verify_RejectsBadBLSPoP pins R6V3: a validator
// whose BLS PoP does not verify against the embedded BLS pubkey must be
// rejected. Construct a valid validator, then scramble the PoP bytes
// before encoding.
func TestCreateSovereignL1Tx_Verify_RejectsBadBLSPoP(t *testing.T) {
stub := OwnerStub{Threshold: 1, Locktime: 0, Address: ids.ShortID{0x42}}
good := makeValidVerifyingValidator(t)
bad := good
// Scramble the PoP into uniform random bytes — pairing fails.
if _, err := rand.Read(bad.BLSPoP[:]); err != nil {
t.Fatalf("rand.Read: %v", err)
}
tx := NewCreateSovereignL1Tx(CreateSovereignL1TxInput{
NetworkID: 1,
Owner: stub,
Validators: []ValidatorsListEntry{bad},
Chains: []ChainsListEntry{makeValidChainsEntry()},
})
err := tx.Verify()
if !errors.Is(err, ErrBadBLSPoP) {
t.Fatalf("Verify(bad PoP) = %v, want ErrBadBLSPoP", err)
}
}
// TestCreateSovereignL1Tx_Verify_RejectsBadBLSPoP_MismatchedPubKey pins
// R6V3 against the keypair-substitution attack: an adversary takes a
// valid PoP from validator A and pairs it with validator B's pubkey,
// hoping the wire-level verify lets it through. The pairing must reject.
func TestCreateSovereignL1Tx_Verify_RejectsBadBLSPoP_MismatchedPubKey(t *testing.T) {
stub := OwnerStub{Threshold: 1, Locktime: 0, Address: ids.ShortID{0x42}}
a := makeValidVerifyingValidator(t)
b := makeValidVerifyingValidator(t)
// Splice: keep B's pubkey, swap in A's PoP. PoP signs A's pubkey, not B's.
hybrid := b
hybrid.BLSPoP = a.BLSPoP
tx := NewCreateSovereignL1Tx(CreateSovereignL1TxInput{
NetworkID: 1,
Owner: stub,
Validators: []ValidatorsListEntry{hybrid},
Chains: []ChainsListEntry{makeValidChainsEntry()},
})
err := tx.Verify()
if !errors.Is(err, ErrBadBLSPoP) {
t.Fatalf("Verify(mismatched-keypair PoP) = %v, want ErrBadBLSPoP", err)
}
}
// TestChainsList_Verify_RejectsBadFxIDsLen pins R6V5: a chain entry whose
// FxIDsLen is not a multiple of FxIDSize must be rejected. We tamper the
// wire buffer post-encode to set FxIDsLen=63 (not a multiple of 32) and
// Verify must catch it.
func TestChainsList_Verify_RejectsBadFxIDsLen(t *testing.T) {
stub := OwnerStub{Threshold: 1, Locktime: 0, Address: ids.ShortID{0x42}}
// Build a legitimate tx with one chain whose FxIDs has 2 entries (64B).
chain := ChainsListEntry{
Name: []byte("evm"),
VMID: ids.ID{0xed},
FxIDs: []ids.ID{{0x01}, {0x02}},
GenesisData: []byte("g"),
}
tx := NewCreateSovereignL1Tx(CreateSovereignL1TxInput{
NetworkID: 1,
Owner: stub,
Validators: []ValidatorsListEntry{makeValidVerifyingValidator(t)},
Chains: []ChainsListEntry{chain},
})
// Pre-tamper sanity: Verify must succeed.
if err := tx.Verify(); err != nil {
t.Fatalf("baseline Verify = %v, want nil", err)
}
// Tamper: locate ChainEntry.FxIDsLen in the wire buffer. The legitimate
// value is 64 (=2 * FxIDSize). We scan for the unique 4-byte LE pattern
// {0x40, 0x00, 0x00, 0x00} and overwrite with 63.
//
// To make the search unique we also pin neighborhood context: the
// VMID's first byte (0xed) lives at OffsetChainEntry_VMID (=8) within
// the entry, and FxIDsLen lives at offset 44. So we search for
// 0xed followed by 31 trailing VMID bytes, then 4 bytes of FxIDsRel
// (=0), then the {0x40,0,0,0} length pattern.
buf := tx.Bytes()
tampered := make([]byte, len(buf))
copy(tampered, buf)
patched := false
for i := 0; i+SizeChainEntry <= len(tampered); i++ {
if tampered[i] != 0xed {
continue
}
// Check rest of VMID is zero (we set VMID = {0xed} only).
zeroVMID := true
for j := 1; j < 32; j++ {
if tampered[i+j] != 0 {
zeroVMID = false
break
}
}
if !zeroVMID {
continue
}
// FxIDsRel at +32, FxIDsLen at +36. FxIDsRel should be 0 (first entry).
if tampered[i+32] != 0 || tampered[i+33] != 0 || tampered[i+34] != 0 || tampered[i+35] != 0 {
continue
}
// FxIDsLen at +36 should be 64 (= 2 * 32).
if tampered[i+36] != 0x40 || tampered[i+37] != 0 || tampered[i+38] != 0 || tampered[i+39] != 0 {
continue
}
// Patch FxIDsLen to 63 (malformed).
tampered[i+36] = 0x3F
patched = true
break
}
if !patched {
t.Fatalf("could not locate ChainEntry.FxIDsLen in wire buffer")
}
tamperedTx, err := WrapCreateSovereignL1Tx(tampered)
if err != nil {
t.Fatalf("WrapCreateSovereignL1Tx(tampered) = %v, want nil (parser permissive)", err)
}
// Confirm the tamper landed in the wire.
gotEntry := tamperedTx.Chains().At(0)
if _, length := gotEntry.FxIDsRange(); length != 63 {
t.Fatalf("tampered FxIDsLen = %d, want 63", length)
}
// Verify must reject.
if err := tamperedTx.Verify(); !errors.Is(err, ErrMalformedFxIDsLen) {
t.Fatalf("Verify(malformed FxIDsLen) = %v, want ErrMalformedFxIDsLen", err)
}
}
// TestChainsListView_Verify_StandaloneEntries exercises the helper on a
// hand-constructed ChainsListView so Verify 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) {
// Good entry: FxIDsLen = 2*32 = 64.
entries := []ChainsListEntry{
{Name: []byte("a"), VMID: ids.ID{0x01}, FxIDs: []ids.ID{{0xff}, {0xee}}},
{Name: []byte("b"), VMID: ids.ID{0x02}, FxIDs: nil},
}
tx := NewCreateSovereignL1Tx(CreateSovereignL1TxInput{
NetworkID: 1,
Owner: OwnerStub{Threshold: 1, Address: ids.ShortID{0x55}},
Validators: []ValidatorsListEntry{makeValidVerifyingValidator(t)},
Chains: entries,
})
if err := tx.Chains().Verify(); err != nil {
t.Fatalf("ChainsListView.Verify = %v, want nil", err)
}
}
// TestTransferChainOwnershipTx_Verify_RejectsZeroThreshold pins R6V8:
// the new Verify() must reject threshold=0. Constructor path — the v3
// tx accepts any (threshold, locktime, address) so we can build a
// malicious-by-construction tx and prove Verify catches it.
func TestTransferChainOwnershipTx_Verify_RejectsZeroThreshold(t *testing.T) {
addr := ids.ShortID{0x42}
tx := NewTransferChainOwnershipTx(ids.ID{0xCA}, 0, 0, addr)
err := tx.Verify()
if !errors.Is(err, ErrOwnerThresholdZero) {
t.Fatalf("Verify(threshold=0) = %v, want ErrOwnerThresholdZero", err)
}
}
// TestTransferChainOwnershipTx_Verify_RejectsThresholdAboveOne pins R6V8:
// the v3 tx pins a single-address Owner, so threshold > 1 is
// unsatisfiable (only one signer, quorum > 1 can never be reached). The
// underlying OwnerStub.SyntacticVerify returns ErrOwnerThresholdExceedsAddrs.
func TestTransferChainOwnershipTx_Verify_RejectsThresholdAboveOne(t *testing.T) {
addr := ids.ShortID{0x42}
tx := NewTransferChainOwnershipTx(ids.ID{0xCA}, 7, 0, addr)
err := tx.Verify()
if !errors.Is(err, ErrOwnerThresholdExceedsAddrs) {
t.Fatalf("Verify(threshold=7) = %v, want ErrOwnerThresholdExceedsAddrs", err)
}
}
// TestTransferChainOwnershipTx_Verify_AdversarialWireBuffer pins R6V8
// against the strongest threat model: adversary controls the wire byte
// stream. Build a legitimate tx, patch the threshold byte in-place to 0,
// re-Wrap, and prove Verify rejects.
func TestTransferChainOwnershipTx_Verify_AdversarialWireBuffer(t *testing.T) {
addr := ids.ShortID{0x77}
good := NewTransferChainOwnershipTx(ids.ID{0xCA, 0xCA, 0xCA, 0xCA}, 1, 0, addr)
buf := good.Bytes()
// Re-parse so we can probe the live root.Uint32 location, then patch.
msg, err := zap.Parse(buf)
if err != nil {
t.Fatalf("Parse: %v", err)
}
if got := msg.Root().Uint32(OffsetTransferChainOwnershipTx_OwnerThreshold); got != 1 {
t.Fatalf("pre-overwrite threshold = %d, want 1", got)
}
tampered := make([]byte, len(buf))
copy(tampered, buf)
// Unique pattern: Chain field begins 0xCA,0xCA,0xCA,0xCA at
// OffsetTransferChainOwnershipTx_Chain (=1) within the root payload.
// Threshold uint32 (LE) = {0x01,0,0,0} sits 32 bytes after the chain
// start. Locktime uint64 (LE) zeros for 8 bytes, then 0x77 marks
// OwnerAddress[0].
patched := false
// Search bound: we need bytes i..i+44 to be the (Chain, threshold,
// locktime, addr[0]) span — that's 45 bytes, so i+45 <= len(tampered)
// is sufficient. SizeTransferChainOwnershipTx (=65) would over-clamp
// when the tx sits at the buffer's tail (no trailing slack).
for i := 0; i+45 <= len(tampered); i++ {
if tampered[i] != 0xCA || tampered[i+1] != 0xCA ||
tampered[i+2] != 0xCA || tampered[i+3] != 0xCA {
continue
}
// Threshold at +32.
if tampered[i+32] != 0x01 || tampered[i+33] != 0 ||
tampered[i+34] != 0 || tampered[i+35] != 0 {
continue
}
// Locktime at +36 should be 8 zero bytes.
zero := true
for j := 36; j < 44; j++ {
if tampered[i+j] != 0 {
zero = false
break
}
}
if !zero {
continue
}
// OwnerAddress[0] at +44.
if tampered[i+44] != 0x77 {
continue
}
// Patch threshold to 0.
tampered[i+32] = 0
patched = true
break
}
if !patched {
t.Fatalf("could not locate threshold field in wire buffer")
}
tamperedTx, err := WrapTransferChainOwnershipTx(tampered)
if err != nil {
t.Fatalf("WrapTransferChainOwnershipTx(tampered) = %v, want nil (parser permissive)", err)
}
if tamperedTx.OwnerThreshold() != 0 {
t.Fatalf("tampered threshold = %d, want 0", tamperedTx.OwnerThreshold())
}
if err := tamperedTx.Verify(); !errors.Is(err, ErrOwnerThresholdZero) {
t.Fatalf("Verify(tampered) = %v, want ErrOwnerThresholdZero", err)
}
}
// TestCreateSovereignL1Tx_Verify_AdversarialWireBuffer_ValidatorWeight
// pins R6V4 against wire tampering: build a legitimate tx then patch
// the second validator's Weight to 0 in the wire buffer.
func TestCreateSovereignL1Tx_Verify_AdversarialWireBuffer_ValidatorWeight(t *testing.T) {
stub := OwnerStub{Threshold: 1, Locktime: 0, Address: ids.ShortID{0x42}}
v0 := makeValidVerifyingValidator(t)
v1 := makeValidVerifyingValidator(t)
// Make v1's NodeID uniquely identifiable so we can find its record.
v1.NodeID = ids.NodeID{0xDE, 0xAD, 0xBE, 0xEF}
v1.Weight = 0x4242_4242 // Will patch to 0
tx := NewCreateSovereignL1Tx(CreateSovereignL1TxInput{
NetworkID: 1,
Owner: stub,
Validators: []ValidatorsListEntry{v0, v1},
Chains: []ChainsListEntry{makeValidChainsEntry()},
})
// Sanity: baseline Verify passes.
if err := tx.Verify(); err != nil {
t.Fatalf("baseline Verify = %v, want nil", err)
}
buf := tx.Bytes()
tampered := make([]byte, len(buf))
copy(tampered, buf)
patched := false
for i := 0; i+SizeValidatorRecord <= len(tampered); i++ {
// Look for NodeID 0xDE,0xAD,0xBE,0xEF prefix.
if tampered[i] != 0xDE || tampered[i+1] != 0xAD ||
tampered[i+2] != 0xBE || tampered[i+3] != 0xEF {
continue
}
// NodeID is 20 bytes — rest should be zero per the constructor's
// ids.NodeID{0xDE,0xAD,0xBE,0xEF} layout.
zeroTail := true
for j := 4; j < 20; j++ {
if tampered[i+j] != 0 {
zeroTail = false
break
}
}
if !zeroTail {
continue
}
// Weight at +20 (LE uint64) should be 0x4242_4242 = {0x42,0x42,0x42,0x42,0,0,0,0}.
if tampered[i+20] != 0x42 || tampered[i+21] != 0x42 ||
tampered[i+22] != 0x42 || tampered[i+23] != 0x42 {
continue
}
// Patch Weight to 0.
for j := 20; j < 28; j++ {
tampered[i+j] = 0
}
patched = true
break
}
if !patched {
t.Fatalf("could not locate validator weight in wire buffer")
}
tamperedTx, err := WrapCreateSovereignL1Tx(tampered)
if err != nil {
t.Fatalf("WrapCreateSovereignL1Tx(tampered) = %v, want nil (parser permissive)", err)
}
if rec := tamperedTx.Validators().At(1); rec.Weight() != 0 {
t.Fatalf("tampered Weight = %d, want 0", rec.Weight())
}
if err := tamperedTx.Verify(); !errors.Is(err, ErrValidatorWeightZero) {
t.Fatalf("Verify(tampered weight=0) = %v, want ErrValidatorWeightZero", err)
}
}
// Sanity: confirm the BLS package surface we depend on is reachable at
// link time (catches a future luxfi/crypto refactor that moves the
// VerifyProofOfPossession symbol).
func TestBLSSurfaceReachable(t *testing.T) {
_ = bls.VerifyProofOfPossession
}
+154 -4
View File
@@ -4,8 +4,10 @@
package zap_native
import (
"errors"
"fmt"
"github.com/luxfi/crypto/bls"
"github.com/luxfi/ids"
)
@@ -21,8 +23,77 @@ import (
// > len(addrs).
//
// Contract: each Verify() returns a wrapped typed error from
// {ErrOwnerThresholdZero, ErrOwnerThresholdExceedsAddrs, ErrOwnerAddrsEmpty}.
// errors.Is on the typed error MUST match.
// {ErrOwnerThresholdZero, ErrOwnerThresholdExceedsAddrs, ErrOwnerAddrsEmpty,
// ErrZeroValidators, ErrZeroChains, ErrValidatorWeightZero, ErrBadBLSPoP,
// ErrMalformedFxIDsLen}. errors.Is on the typed error MUST match.
//
// LP-023 Red round 6 closes:
// - R6V4 (CRITICAL): CreateSovereignL1Tx.Verify must reject zero-validator
// and zero-chain wire buffers — both would create an L1 that halts at
// activation. Per-validator Weight > 0 walk also fires here.
// - R6V8 (HIGH): TransferChainOwnershipTx.Verify was missing entirely
// despite the tx carrying Owner fields. Wire it into the same Verify()
// contract every other Owner-bearing tx uses.
// - R6V3 (HIGH): BLS proof-of-possession must be verified for every
// initial validator at the SyntacticVerify boundary. Wire layer returns
// opaque 48B pubkey + 96B PoP; no downstream consumer was calling
// bls.VerifyProofOfPossession. Wired into CreateSovereignL1Tx.Verify
// so the pairing check fires once per validator on the executor path.
// - R6V5 (MEDIUM): ChainsListView.Verify walks entries and rejects any
// entry whose FxIDsLen is not a multiple of FxIDSize. Silent-nil
// return from BoundChainEntry.FxIDs would otherwise treat a malformed
// length as "no FxIDs" and silently accept the tx.
// R6V4 + R6V3 + R6V5 typed errors. Each is wrapped by CreateSovereignL1Tx.Verify
// with tx-kind context; consumers match via errors.Is.
var (
// ErrZeroValidators is returned when the initial-validator set of a
// CreateSovereignL1Tx is empty. A zero-validator L1 halts consensus
// at activation (no quorum can ever be reached). Reject at the wire
// boundary, not at chain bring-up time.
ErrZeroValidators = errors.New(
"zap_native: Validators list is empty — L1 cannot bootstrap consensus",
)
// ErrZeroChains is returned when the chains-to-create list of a
// CreateSovereignL1Tx is empty. An L1 with no chains has no surface;
// the tx commits no state. Reject as malformed.
ErrZeroChains = errors.New(
"zap_native: Chains list is empty — L1 has no chains to create",
)
// ErrValidatorWeightZero is returned when any validator entry has a
// stake weight of zero. A zero-weight validator contributes nothing
// to quorum, so it is either a constructor mistake or a deliberate
// quorum-skewing attack (filler entries that pad the count but do
// not contribute to the threshold).
ErrValidatorWeightZero = errors.New(
"zap_native: validator Weight must be > 0; zero-weight validator skews quorum",
)
// ErrBadBLSPoP is returned when any validator entry's BLS
// proof-of-possession fails to verify against the embedded BLS
// public key. The pairing check binds the BLS keypair to the
// pubkey value; without it an adversary could substitute an
// arbitrary pubkey/PoP pair and seize validator authority on the
// new L1 at registration time. Wire layer is opaque about pairing
// validity; this gate is the only place it fires before the
// executor commits the validator set.
ErrBadBLSPoP = errors.New(
"zap_native: validator BLSPoP failed pairing verification",
)
// ErrMalformedFxIDsLen is returned when a chain entry's FxIDsLen is
// not an exact multiple of FxIDSize. The wire layer's
// BoundChainEntry.FxIDs silently returns nil for this case
// (fail-closed at access time), but a downstream consumer that
// looks at .FxIDsRange() length directly — or treats the empty
// slice as "no FxIDs allowed" — could be coerced into accepting a
// tx with a malformed fx-id geometry. Reject at the wire boundary.
ErrMalformedFxIDsLen = errors.New(
"zap_native: ChainEntry.FxIDsLen must be an exact multiple of FxIDSize (32)",
)
)
// stubFromTuple reconstructs an OwnerStub from the (threshold, locktime,
// address) tuple returned by the embedded-stub accessors. The wire layer
@@ -95,12 +166,91 @@ func (t CreateNetworkTx) Verify() error {
return nil
}
// Verify runs SyntacticVerify on the embedded Owner of a
// CreateSovereignL1Tx.
// Verify runs SyntacticVerify on the embedded Owner of a CreateSovereignL1Tx
// AND enforces R6V4 / R6V3 / R6V5: the initial validator set must be
// non-empty, every validator must have non-zero weight, every validator's
// BLS PoP must verify, the chains list must be non-empty, and every chain
// entry's FxIDsLen must be an exact multiple of FxIDSize.
//
// Notes:
// - Per-validator RegistrationExpiry > now() is intentionally NOT enforced
// here. SyntacticVerify is clock-independent (executor wall-clock lives
// in the staking handler), so the expiry check lives there. This file
// enforces only properties that are invariant under wire encoding.
// - The BLS pairing check is fast enough at the SyntacticVerify boundary
// for small validator lists (< 100). For large lists the cost is
// O(n) pairings, still well under the executor budget.
func (t CreateSovereignL1Tx) Verify() error {
o := stubFromTuple(t.Owner())
if err := o.SyntacticVerify(); err != nil {
return fmt.Errorf("CreateSovereignL1Tx.Owner: %w", err)
}
// R6V4: non-empty validators + per-validator weight > 0.
// R6V3: BLS PoP verification per validator.
vals := t.Validators()
n := vals.Len()
if n == 0 {
return fmt.Errorf("CreateSovereignL1Tx.Validators: %w", ErrZeroValidators)
}
for i := 0; i < n; i++ {
rec := vals.At(i)
if rec.Weight() == 0 {
return fmt.Errorf(
"CreateSovereignL1Tx.Validators[%d].Weight: %w", i, ErrValidatorWeightZero,
)
}
// BLS PoP gate — R6V3.
pkBytes := rec.BLSPubKey()
sigBytes := rec.BLSPoP()
pk, err := bls.PublicKeyFromCompressedBytes(pkBytes)
if err != nil {
return fmt.Errorf(
"CreateSovereignL1Tx.Validators[%d].BLSPubKey: %w (%v)",
i, ErrBadBLSPoP, err,
)
}
sig, err := bls.SignatureFromBytes(sigBytes)
if err != nil {
return fmt.Errorf(
"CreateSovereignL1Tx.Validators[%d].BLSPoP: %w (%v)",
i, ErrBadBLSPoP, err,
)
}
if !bls.VerifyProofOfPossession(pk, sig, pkBytes) {
return fmt.Errorf(
"CreateSovereignL1Tx.Validators[%d].BLSPoP: %w",
i, ErrBadBLSPoP,
)
}
}
// R6V4: non-empty chains.
// R6V5: every entry's FxIDsLen must be an exact multiple of FxIDSize.
chains := t.Chains()
if chains.Len() == 0 {
return fmt.Errorf("CreateSovereignL1Tx.Chains: %w", ErrZeroChains)
}
if err := chains.Verify(); err != nil {
return fmt.Errorf("CreateSovereignL1Tx.Chains: %w", err)
}
return nil
}
// Verify reconstructs an OwnerStub from a TransferChainOwnershipTx's
// (threshold, locktime, address) tuple and runs SyntacticVerify on it. The
// tx carries Owner fields inline but had no Verify() until LP-023 Red
// round 6 R6V8 — every other Owner-bearing tx is gated, and skipping this
// one allowed an adversary to publish a chain-ownership transfer with
// threshold=0 (no signer required) or threshold>1 (unsatisfiable, DoS).
//
// Note: TransferChainOwnershipTx pins a single-address Owner in v3 (the
// most common configuration), so OwnerStub.SyntacticVerify is the right
// gate — it accepts threshold=1 only.
func (t TransferChainOwnershipTx) Verify() error {
o := stubFromTuple(t.OwnerThreshold(), t.OwnerLocktime(), t.OwnerAddress())
if err := o.SyntacticVerify(); err != nil {
return fmt.Errorf("TransferChainOwnershipTx.Owner: %w", err)
}
return nil
}
@@ -7,10 +7,53 @@ import (
"errors"
"testing"
"github.com/luxfi/crypto/bls"
"github.com/luxfi/crypto/bls/signer/localsigner"
"github.com/luxfi/ids"
"github.com/luxfi/zap"
)
// makeValidVerifyingValidator mints a real BLS keypair and returns a
// ValidatorsListEntry whose PoP passes bls.VerifyProofOfPossession. Used
// by tests that want a well-formed CreateSovereignL1Tx and need the R6V3
// PoP gate to succeed. Not used at runtime — keep this in tests only.
func makeValidVerifyingValidator(t *testing.T) ValidatorsListEntry {
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()
pkBytes := bls.PublicKeyToCompressedBytes(pk)
sig, err := ls.SignProofOfPossession(pkBytes)
if err != nil {
t.Fatalf("SignProofOfPossession: %v", err)
}
sigBytes := bls.SignatureToBytes(sig)
var rec ValidatorsListEntry
rec.NodeID = ids.NodeID{0x77}
rec.Weight = 1_000_000
copy(rec.BLSPubKey[:], pkBytes)
copy(rec.BLSPoP[:], sigBytes)
rec.RegistrationExpiry = 1_900_000_000
return rec
}
// makeValidChainsEntry returns a ChainsListEntry whose FxIDsLen is a
// proper multiple of FxIDSize (R6V5 must pass).
func makeValidChainsEntry() ChainsListEntry {
return ChainsListEntry{
Name: []byte("evm"),
VMID: ids.ID{0xed, 0xed},
FxIDs: []ids.ID{{0x01}},
GenesisData: []byte(`{"config":{}}`),
}
}
// TestVerify_AcceptsWellFormed walks every tx type that embeds an
// OwnerStub and pins that Verify() returns nil on a well-formed tx.
// Threshold=1, single address — the legitimate single-signer case.
@@ -62,7 +105,22 @@ func TestVerify_AcceptsWellFormed(t *testing.T) {
}
})
t.Run("CreateSovereignL1", func(t *testing.T) {
tx := NewCreateSovereignL1Tx(CreateSovereignL1TxInput{NetworkID: 1, Owner: stub})
// R6V4 + R6V3 + R6V5: Verify now requires non-empty validators
// with valid BLS PoP, non-empty chains, and well-formed FxIDsLen.
tx := NewCreateSovereignL1Tx(CreateSovereignL1TxInput{
NetworkID: 1,
Owner: stub,
Validators: []ValidatorsListEntry{makeValidVerifyingValidator(t)},
Chains: []ChainsListEntry{makeValidChainsEntry()},
})
if err := tx.Verify(); err != nil {
t.Fatalf("Verify = %v, want nil", err)
}
})
t.Run("TransferChainOwnership", func(t *testing.T) {
// R6V8: Verify is now wired in. The legitimate single-signer
// case (threshold=1) must pass.
tx := NewTransferChainOwnershipTx(ids.ID{0xCA}, 1, 0, addr)
if err := tx.Verify(); err != nil {
t.Fatalf("Verify = %v, want nil", err)
}