mirror of
https://github.com/luxfi/node.git
synced 2026-07-27 03:39:39 +00:00
#189: LP-023 batch 5 v3.5 — RESERVED bytes zero-gate (R6-4) + AddressList CI audit gate (R6-6) + cross-blob aliasing documented contract (R6-2 design decision)
R6-4 (RESERVED zero-gate)
- ChainEntry RESERVED bytes [56..64) gated by ChainsListView.Verify.
- New OffsetChainEntry_Reserved=56 constant + ErrReservedNonZero typed err.
- Writer already zero-pads; gate prevents adversary smuggling state inside
what consensus considers empty. Pins the upgrade-safe invariant before
any v4 parser attaches meaning to those bytes (no silent wire-fork).
- Tests: TestChainsList_Verify_RejectsNonZeroReserved (per-byte sweep of
all 8 reserved bytes, each flipped individually -> all reject),
TestChainsList_Verify_RejectsNonZeroReserved_TopByte (top byte 0x01).
R6-6 (AddressList.At CI audit gate)
- .github/workflows/zap-audit.yml: grep-based gate on PR + push to
main/dev. Fails if any new production caller of AddressList.At()
appears outside the allowlist (_test.go, tx_verify.go, owner.go,
audit_test.go).
- vms/platformvm/txs/zap_native/audit_test.go: local mirror so
`go test ./vms/platformvm/txs/zap_native/` reproduces CI behavior.
Verified locally: clean -> PASS; injected violator -> trips correctly.
R6-2 (cross-blob aliasing design decision)
- Documented-allowance path: aliasing is ALLOWED. Wire layer must not
reject overlapping (rel, len) ranges; identity is (VMID, BlockchainID),
not Name() bytes.
- Full CONTRACT block on ChainsListView: (1) Name/FxIDs/GenesisData
return payload slices not identity, (2) chain identity is VMID +
BlockchainID, (3) consumers MUST NOT use returned bytes as dedup keys,
(4) returned slices are read-only.
- Forward path documented: if future feature requires non-overlap, add
ChainsListView.VerifyNonOverlappingRanges() and wire it from tx.Verify.
- Test: TestChainsList_AllowsOverlappingRanges_DocumentedContract -
patches chain[1].NameRel to alias chain[0]'s slice, proves Verify
accepts AND Name(0) == Name(1) byte-equal, AND VMID(0) != VMID(1).
go test -race ./vms/platformvm/txs/zap_native/...: ok 1.4s
Builds on top of v3 commit d5c305d440.
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
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.
|
||||
#
|
||||
# Allowed callers:
|
||||
# - *_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:
|
||||
# 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.
|
||||
#
|
||||
# Local reproduction:
|
||||
# cd vms/platformvm/txs/zap_native && go test -run TestAuditGate -v
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "vms/platformvm/txs/zap_native/**"
|
||||
- ".github/workflows/zap-audit.yml"
|
||||
push:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- "vms/platformvm/txs/zap_native/**"
|
||||
- ".github/workflows/zap-audit.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
addresslist-production-gate:
|
||||
name: AddressList.At() — no production consumers
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Grep for production consumers
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
hits=$(grep -rn 'AddressList\.At(' --include='*.go' vms/platformvm/txs/zap_native/ \
|
||||
| grep -vE '(_test\.go|tx_verify\.go|owner\.go)' \
|
||||
|| true)
|
||||
if [ -n "$hits" ]; then
|
||||
echo "ERROR: New production consumer(s) of AddressList.At() found."
|
||||
echo "AddressList is forward-looking — see"
|
||||
echo " vms/platformvm/txs/zap_native/owner.go"
|
||||
echo "for the consumer-safety contract."
|
||||
echo ""
|
||||
echo "If you have a legitimate production use, either:"
|
||||
echo " 1. Land the owner-model migration that retires AddressList.At, OR"
|
||||
echo " 2. Add the file to the allowlist in"
|
||||
echo " .github/workflows/zap-audit.yml AND"
|
||||
echo " vms/platformvm/txs/zap_native/audit_test.go"
|
||||
echo " with the equivalence proof recorded in owner.go."
|
||||
echo ""
|
||||
echo "Offenders:"
|
||||
echo "$hits"
|
||||
exit 1
|
||||
fi
|
||||
echo "Audit clean: zero AddressList.At() production consumers"
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package zap_native
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAuditGate_AddressListNoProductionConsumers mirrors the grep gate in
|
||||
// .github/workflows/zap-audit.yml so a contributor can reproduce the CI
|
||||
// failure locally with `go test ./vms/platformvm/txs/zap_native/`.
|
||||
//
|
||||
// LP-023 R6-6: AddressList.At() must remain non-production until the
|
||||
// owner-model migration retires it OR an executor-side equivalence proof
|
||||
// lands. The allowlist:
|
||||
//
|
||||
// - *_test.go — tests
|
||||
// - tx_verify.go — executor-side SyntacticVerify boundary
|
||||
// - owner.go — the type's own implementation
|
||||
// - audit_test.go — this file (its own grep pattern would self-match)
|
||||
//
|
||||
// To LEGITIMATELY introduce a production caller, land the migration AND
|
||||
// update the allowlist in BOTH this test and the workflow YAML.
|
||||
func TestAuditGate_AddressListNoProductionConsumers(t *testing.T) {
|
||||
out, err := exec.Command("sh", "-c",
|
||||
`grep -rn 'AddressList\.At(' --include='*.go' . | grep -vE '(_test\.go|tx_verify\.go|owner\.go|audit_test\.go)' || true`,
|
||||
).Output()
|
||||
if err != nil {
|
||||
t.Fatalf("grep gate exec failed: %v", err)
|
||||
}
|
||||
s := strings.TrimSpace(string(out))
|
||||
if s != "" {
|
||||
t.Fatalf(
|
||||
"AddressList.At() production consumer(s) found.\n"+
|
||||
"AddressList is forward-looking; if you have a legitimate use,\n"+
|
||||
"either land the owner-model migration or add an allowlist entry\n"+
|
||||
"with the equivalence proof recorded in owner.go.\n\n"+
|
||||
"Offenders:\n%s",
|
||||
s,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -41,6 +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)
|
||||
SizeChainEntry = 64
|
||||
|
||||
// FxIDSize is the wire size of one chain ID inside the FxIDs blob.
|
||||
@@ -131,6 +132,47 @@ func (e ChainEntry) GenesisDataRange() (uint32, uint32) {
|
||||
//
|
||||
// Compile-time enforcement: ChainsListView lacks the safe accessors so
|
||||
// consumers cannot bypass Bind() by accident.
|
||||
//
|
||||
// CROSS-BLOB ALIASING ALLOWANCE (LP-023 Red round 6 R6-2 design decision):
|
||||
//
|
||||
// A wire-encoded ChainsList may legitimately encode overlapping
|
||||
// `(rel, len)` ranges across entries pointing into the shared
|
||||
// NameBlobs / FxIDsBlobs / GenesisDataBlobs sibling arrays. For example,
|
||||
// two chains with the same VMID may share the same FxIDsBlob range; two
|
||||
// chains that share a name template can share the same NameBlob range.
|
||||
// The writer (WriteChainsList) concatenates without dedup, so today
|
||||
// this is a "won't happen by accident" path — but the WIRE layer must
|
||||
// not reject it, because a future writer optimization may emit aliased
|
||||
// ranges to shrink the blob arrays.
|
||||
//
|
||||
// CONTRACT (the wire layer makes these promises; consumers must respect them):
|
||||
//
|
||||
// 1. Name(), FxIDs(), GenesisData() return PAYLOAD slices into the
|
||||
// shared blob, NOT identity. Two distinct entries whose `(rel, len)`
|
||||
// windows overlap will return byte-equal (or byte-overlapping) slices.
|
||||
//
|
||||
// 2. Chain IDENTITY is the (VMID, BlockchainID) pair set by the
|
||||
// executor via CreateChainTx. Wire-layer Name is descriptive metadata,
|
||||
// not consensus-binding. The executor never derives identity from
|
||||
// Name() bytes.
|
||||
//
|
||||
// 3. Consumers MUST NOT use returned bytes as a deduplication key.
|
||||
// Two entries returning the same Name() bytes are two distinct chains
|
||||
// on the network if their (VMID, BlockchainID) differ. Using Name()
|
||||
// as a set membership key would silently merge them.
|
||||
//
|
||||
// 4. Returned slices are READ-ONLY. Mutating any byte returned from
|
||||
// Name() / FxIDs() / GenesisData() corrupts the parent message AND
|
||||
// any aliased entries simultaneously. The slice headers point into
|
||||
// the wire buffer; there is no copy boundary.
|
||||
//
|
||||
// If a future feature requires per-entry NON-overlapping ranges (e.g.
|
||||
// to enable in-place mutation of one chain's name without affecting
|
||||
// others), add a `ChainsListView.VerifyNonOverlappingRanges() error`
|
||||
// method and call it from the relevant tx's Verify() entry. Until then,
|
||||
// aliasing is allowed and exercised by the
|
||||
// TestChainsList_AllowsOverlappingRanges_DocumentedContract test which
|
||||
// pins the byte-equality guarantee.
|
||||
type ChainsListView struct {
|
||||
list zap.List
|
||||
}
|
||||
@@ -246,19 +288,24 @@ 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).
|
||||
// Verify 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
|
||||
// ignores them lets an adversary smuggle non-zero state inside what
|
||||
// consensus considers an empty region. Today that's invisible; the
|
||||
// day a v4 parser attaches meaning to byte 56 (e.g. a subnet flag),
|
||||
// every v3 tx with non-zero reserved bytes silently means two
|
||||
// different things on v3 vs v4 nodes — a wire-fork. Reject NOW.
|
||||
//
|
||||
// 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).
|
||||
// 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 the first failure wrapped with the entry index. Returns nil
|
||||
// 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 {
|
||||
n := l.Len()
|
||||
for i := 0; i < n; i++ {
|
||||
@@ -267,6 +314,20 @@ func (l ChainsListView) Verify() error {
|
||||
if length%FxIDSize != 0 {
|
||||
return fmt.Errorf("ChainsList[%d].FxIDsLen=%d: %w", i, length, ErrMalformedFxIDsLen)
|
||||
}
|
||||
// R6-4: reserved bytes [56..64) must all be zero. Read via the
|
||||
// raw object surface; bypass IsNull guard because entries we just
|
||||
// iterated are in-range by construction (i < l.Len()).
|
||||
if entry.IsNull() {
|
||||
continue
|
||||
}
|
||||
for off := OffsetChainEntry_Reserved; off < SizeChainEntry; off++ {
|
||||
if entry.obj.Uint8(off) != 0 {
|
||||
return fmt.Errorf(
|
||||
"ChainsList[%d].Reserved[%d]=0x%02x: %w",
|
||||
i, off-OffsetChainEntry_Reserved, entry.obj.Uint8(off), ErrReservedNonZero,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package zap_native
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/ids"
|
||||
)
|
||||
|
||||
// LP-023 batch 5 v3.5 — three remaining gates from Red round 6 brief.
|
||||
//
|
||||
// - R6-4: ChainsList RESERVED bytes [56..64) zero-check.
|
||||
// - R6-2: Cross-blob aliasing — documented allowance contract.
|
||||
//
|
||||
// The R6-6 AddressList CI gate has its own audit_test.go file (mirrors the
|
||||
// workflow grep verbatim so a local `go test -race ./...` reproduces CI
|
||||
// failure).
|
||||
|
||||
// TestChainsList_Verify_RejectsNonZeroReserved pins R6-4: a wire buffer
|
||||
// where any RESERVED byte at offsets [56..64) of any ChainEntry is
|
||||
// non-zero must be rejected by ChainsListView.Verify. We patch byte 56
|
||||
// of the first entry — the wire-fork primitive — and prove the gate
|
||||
// catches it.
|
||||
func TestChainsList_Verify_RejectsNonZeroReserved(t *testing.T) {
|
||||
// Build a legitimate single-chain tx with a unique VMID marker so we
|
||||
// can locate the entry in the wire buffer.
|
||||
chain := ChainsListEntry{
|
||||
Name: []byte("evm"),
|
||||
VMID: ids.ID{0xCC, 0xDD, 0xEE, 0xFF, 0x11, 0x22, 0x33, 0x44},
|
||||
FxIDs: []ids.ID{{0x01}},
|
||||
GenesisData: []byte("g"),
|
||||
}
|
||||
tx := NewCreateSovereignL1Tx(CreateSovereignL1TxInput{
|
||||
NetworkID: 1,
|
||||
Owner: OwnerStub{Threshold: 1, Locktime: 0, Address: ids.ShortID{0x42}},
|
||||
Validators: []ValidatorsListEntry{makeValidVerifyingValidator(t)},
|
||||
Chains: []ChainsListEntry{chain},
|
||||
})
|
||||
if err := tx.Verify(); err != nil {
|
||||
t.Fatalf("baseline Verify = %v, want nil", err)
|
||||
}
|
||||
buf := tx.Bytes()
|
||||
|
||||
// Locate the chain entry. VMID lives at offset +8 within the entry.
|
||||
// Our VMID prefix (8 bytes) is unique enough to anchor the entry
|
||||
// start; offset 56 is then 48 bytes later.
|
||||
tamper := func(byteIdx int, val byte) []byte {
|
||||
out := make([]byte, len(buf))
|
||||
copy(out, buf)
|
||||
found := false
|
||||
for i := 0; i+SizeChainEntry <= len(out); i++ {
|
||||
// VMID at +8 within entry; search for unique prefix.
|
||||
if out[i+8] != 0xCC || out[i+9] != 0xDD ||
|
||||
out[i+10] != 0xEE || out[i+11] != 0xFF ||
|
||||
out[i+12] != 0x11 || out[i+13] != 0x22 ||
|
||||
out[i+14] != 0x33 || out[i+15] != 0x44 {
|
||||
continue
|
||||
}
|
||||
// Sanity: confirm reserved currently zero.
|
||||
if out[i+OffsetChainEntry_Reserved+byteIdx] != 0 {
|
||||
continue
|
||||
}
|
||||
out[i+OffsetChainEntry_Reserved+byteIdx] = val
|
||||
found = true
|
||||
break
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("could not locate ChainEntry to tamper byte %d", byteIdx)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Per-byte sweep: flip each of bytes 56..63 individually, prove all
|
||||
// eight reject.
|
||||
for off := 0; off < (SizeChainEntry - OffsetChainEntry_Reserved); off++ {
|
||||
tampered := tamper(off, 0xFF)
|
||||
tamperedTx, err := WrapCreateSovereignL1Tx(tampered)
|
||||
if err != nil {
|
||||
t.Fatalf("offset=%d WrapCreateSovereignL1Tx = %v, want nil (parser permissive)", off, err)
|
||||
}
|
||||
err = tamperedTx.Verify()
|
||||
if !errors.Is(err, ErrReservedNonZero) {
|
||||
t.Fatalf("offset=%d Verify = %v, want ErrReservedNonZero", off, err)
|
||||
}
|
||||
}
|
||||
|
||||
// All-zero reserved (baseline) accepts.
|
||||
if err := tx.Verify(); err != nil {
|
||||
t.Fatalf("zero-reserved baseline Verify = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChainsList_Verify_RejectsNonZeroReserved_TopByte pins the high
|
||||
// reserved byte specifically — the byte most likely to be ignored by a
|
||||
// v4 parser that adds a flag at offset 56 only.
|
||||
func TestChainsList_Verify_RejectsNonZeroReserved_TopByte(t *testing.T) {
|
||||
chain := ChainsListEntry{
|
||||
Name: []byte("evm"),
|
||||
VMID: ids.ID{0xAB, 0xCD, 0xEF, 0x01},
|
||||
FxIDs: []ids.ID{{0x01}},
|
||||
GenesisData: []byte("g"),
|
||||
}
|
||||
tx := NewCreateSovereignL1Tx(CreateSovereignL1TxInput{
|
||||
NetworkID: 1,
|
||||
Owner: OwnerStub{Threshold: 1, Address: ids.ShortID{0x42}},
|
||||
Validators: []ValidatorsListEntry{makeValidVerifyingValidator(t)},
|
||||
Chains: []ChainsListEntry{chain},
|
||||
})
|
||||
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+SizeChainEntry <= len(tampered); i++ {
|
||||
if tampered[i+8] != 0xAB || tampered[i+9] != 0xCD ||
|
||||
tampered[i+10] != 0xEF || tampered[i+11] != 0x01 {
|
||||
continue
|
||||
}
|
||||
// Top byte of reserved at +63.
|
||||
if tampered[i+OffsetChainEntry_Reserved+7] != 0 {
|
||||
continue
|
||||
}
|
||||
tampered[i+OffsetChainEntry_Reserved+7] = 0x01
|
||||
patched = true
|
||||
break
|
||||
}
|
||||
if !patched {
|
||||
t.Fatalf("could not locate ChainEntry top-reserved byte")
|
||||
}
|
||||
tamperedTx, err := WrapCreateSovereignL1Tx(tampered)
|
||||
if err != nil {
|
||||
t.Fatalf("Wrap = %v", err)
|
||||
}
|
||||
if err := tamperedTx.Verify(); !errors.Is(err, ErrReservedNonZero) {
|
||||
t.Fatalf("Verify(top-reserved=1) = %v, want ErrReservedNonZero", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChainsList_AllowsOverlappingRanges_DocumentedContract pins R6-2:
|
||||
// the wire layer ACCEPTS overlapping (rel, len) ranges across distinct
|
||||
// entries pointing into the shared NameBlobs / FxIDsBlobs /
|
||||
// GenesisDataBlobs arrays. Two entries with identical cursors return
|
||||
// byte-equal Name/FxIDs/GenesisData payloads. This is the documented
|
||||
// allowance: identity is the (VMID, BlockchainID) pair set by the
|
||||
// executor, NOT the Name bytes.
|
||||
//
|
||||
// To exercise the allowance we construct a two-chain wire buffer where
|
||||
// chain 0 and chain 1 both have `NameRel=0, NameLen=N`. The writer
|
||||
// (WriteChainsList) concatenates without dedup so the natural path won't
|
||||
// emit overlapping ranges — we therefore mint a tx with two chains that
|
||||
// have IDENTICAL Name bytes, then post-encode patch chain 1's NameRel
|
||||
// to point at chain 0's NameBlob slice (the simplest aliasing case:
|
||||
// rel=0, len=N). Verify must accept and Name(0) must equal Name(1).
|
||||
func TestChainsList_AllowsOverlappingRanges_DocumentedContract(t *testing.T) {
|
||||
name := []byte("evm-shared-name")
|
||||
// Both chains carry identical names — by default the writer writes
|
||||
// `name` twice into NameBlobs at offsets 0 and len(name). We'll
|
||||
// patch chain 1's NameRel from len(name) to 0 so both point at the
|
||||
// same blob slice.
|
||||
chain0 := ChainsListEntry{
|
||||
Name: name, VMID: ids.ID{0x01}, FxIDs: []ids.ID{{0xfa}}, GenesisData: []byte("g0"),
|
||||
}
|
||||
chain1 := ChainsListEntry{
|
||||
Name: name, VMID: ids.ID{0x02}, FxIDs: []ids.ID{{0xfb}}, GenesisData: []byte("g1"),
|
||||
}
|
||||
tx := NewCreateSovereignL1Tx(CreateSovereignL1TxInput{
|
||||
NetworkID: 1,
|
||||
Owner: OwnerStub{Threshold: 1, Address: ids.ShortID{0x42}},
|
||||
Validators: []ValidatorsListEntry{makeValidVerifyingValidator(t)},
|
||||
Chains: []ChainsListEntry{chain0, chain1},
|
||||
})
|
||||
if err := tx.Verify(); err != nil {
|
||||
t.Fatalf("baseline Verify = %v, want nil", err)
|
||||
}
|
||||
|
||||
// Patch chain 1's NameRel to 0 (alias with chain 0's slice). Find
|
||||
// chain 1 by its unique VMID prefix.
|
||||
buf := tx.Bytes()
|
||||
tampered := make([]byte, len(buf))
|
||||
copy(tampered, buf)
|
||||
patched := false
|
||||
for i := 0; i+SizeChainEntry <= len(tampered); i++ {
|
||||
// Chain 1's VMID byte 0 = 0x02; rest zero. To disambiguate from
|
||||
// chain 0, the NameRel at +0 must currently be len(name).
|
||||
if tampered[i+8] != 0x02 {
|
||||
continue
|
||||
}
|
||||
zeroTail := true
|
||||
for j := 9; j < 8+32; j++ {
|
||||
if tampered[i+j] != 0 {
|
||||
zeroTail = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !zeroTail {
|
||||
continue
|
||||
}
|
||||
// NameRel (uint32 LE) at +0 of entry should be len(name).
|
||||
gotRel := uint32(tampered[i]) | uint32(tampered[i+1])<<8 |
|
||||
uint32(tampered[i+2])<<16 | uint32(tampered[i+3])<<24
|
||||
if gotRel != uint32(len(name)) {
|
||||
continue
|
||||
}
|
||||
// Patch NameRel to 0.
|
||||
tampered[i+0] = 0
|
||||
tampered[i+1] = 0
|
||||
tampered[i+2] = 0
|
||||
tampered[i+3] = 0
|
||||
patched = true
|
||||
break
|
||||
}
|
||||
if !patched {
|
||||
t.Fatalf("could not locate chain 1 NameRel to alias")
|
||||
}
|
||||
tamperedTx, err := WrapCreateSovereignL1Tx(tampered)
|
||||
if err != nil {
|
||||
t.Fatalf("Wrap = %v", err)
|
||||
}
|
||||
// Verify must ACCEPT — the documented allowance.
|
||||
if err := tamperedTx.Verify(); err != nil {
|
||||
t.Fatalf("Verify(aliased ranges) = %v, want nil (R6-2 documented allowance)", err)
|
||||
}
|
||||
// Both entries return byte-equal names — the contract.
|
||||
bc := tamperedTx.BoundChains()
|
||||
if bc.Len() != 2 {
|
||||
t.Fatalf("Len = %d, want 2", bc.Len())
|
||||
}
|
||||
n0 := bc.At(0).Name()
|
||||
n1 := bc.At(1).Name()
|
||||
if !bytes.Equal(n0, n1) {
|
||||
t.Fatalf("Name(0) = %q, Name(1) = %q — aliased ranges should be byte-equal", n0, n1)
|
||||
}
|
||||
if !bytes.Equal(n0, name) {
|
||||
t.Fatalf("Name(0) = %q, want %q", n0, name)
|
||||
}
|
||||
// Identity is the VMID, not Name — chain 0 and chain 1 have distinct VMIDs.
|
||||
if bc.At(0).VMID() == bc.At(1).VMID() {
|
||||
t.Fatalf("VMID(0) == VMID(1) — identity should be distinct")
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,22 @@ var (
|
||||
ErrMalformedFxIDsLen = errors.New(
|
||||
"zap_native: ChainEntry.FxIDsLen must be an exact multiple of FxIDSize (32)",
|
||||
)
|
||||
|
||||
// ErrReservedNonZero is returned when a ChainEntry's RESERVED bytes
|
||||
// at wire offsets [56..64) are not all zero. The writer
|
||||
// (WriteChainsList) emits zero for these 8 bytes — they are reserved
|
||||
// for future expansion (subnet flags, initial supply hint, etc.).
|
||||
// Today the parser ignores them, which means an adversary can
|
||||
// smuggle arbitrary state inside what consensus considers an empty
|
||||
// region. If a v4 parser later attaches meaning to those bytes (e.g.
|
||||
// adds a flag at offset 56), every v3 tx that smuggled non-zero bytes
|
||||
// becomes a silent wire-fork: the same tx now means two different
|
||||
// things on v3 vs v4 nodes. Reject at the wire boundary now, before
|
||||
// any tx is ever accepted with non-zero reserved bytes — that pins
|
||||
// the upgrade-safe invariant for all future expansions.
|
||||
ErrReservedNonZero = errors.New(
|
||||
"zap_native: ChainEntry RESERVED bytes [56..64) must be zero",
|
||||
)
|
||||
)
|
||||
|
||||
// stubFromTuple reconstructs an OwnerStub from the (threshold, locktime,
|
||||
|
||||
Reference in New Issue
Block a user