mirror of
https://github.com/luxfi/chains.git
synced 2026-09-24 00:26:00 +00:00
schain: migrate tx wire json→native ZAP (last codec on S-Chain)
PutManifestTx + AllocateTx now encode as [typeByte][ZAP object] instead of [typeByte][json body]. Fixed-offset ZAP objects (FileIDs/pubkey/sig as variable tails, NodeID/Fingerprint as fixed bytes) — deterministic by construction, TxID = Checksum256(wire). Parser dispatches on data[0], rejects trailing bytes (canonical). Removed the unused generic json Marshal[T]. 11 txs tests green (round-trip all fields incl ML-DSA pubkey/sig, determinism, trailing-byte + bad-type rejection); schain/pinning/state suites green. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
+240
-57
@@ -7,25 +7,33 @@
|
||||
// exist elsewhere (the S-Chain does NOT carry blobs in M0; it carries the
|
||||
// content manifest that names them).
|
||||
//
|
||||
// Wire format mirrors dexvm/txs exactly — a single type byte followed by the
|
||||
// JSON body of the concrete struct (dexvm/txs/tx.go:621). The encoding is
|
||||
// deterministic (encoding/json emits struct fields in declaration order and
|
||||
// these types contain no maps), so the same logical transaction always
|
||||
// serializes to identical bytes and therefore the same TxID. One codec, one way
|
||||
// to read a transaction off the wire.
|
||||
// Wire format is native ZAP: a single type byte (the discriminator the parser
|
||||
// dispatches on) followed by the ZAP wire object of the concrete struct — no
|
||||
// reflection codec, no JSON. Each tx type owns its marshal/parse over a zap
|
||||
// object at fixed field offsets (the same struct-is-wire idiom as chains/mpcvm
|
||||
// and chains/bridgevm). The encoding is deterministic — the builder writes the
|
||||
// fixed section then the variable tails in a fixed field order, and these types
|
||||
// contain no maps — so the same logical transaction always serializes to
|
||||
// identical bytes and therefore the same TxID. Re-genesis is authorized, so the
|
||||
// wire format is these offsets (canonical: parse rejects trailing bytes). One
|
||||
// codec, one way to read a transaction off the wire.
|
||||
package txs
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/zap"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrInvalidTxType is returned when the leading type byte names no known tx.
|
||||
ErrInvalidTxType = errors.New("invalid transaction type")
|
||||
// ErrTrailingBytes rejects a wire object whose ZAP body is shorter than the
|
||||
// bytes supplied — a canonical codec consumes its input exactly.
|
||||
ErrTrailingBytes = errors.New("transaction: trailing bytes after wire object")
|
||||
// ErrEmptyBucket / ErrEmptyObject reject a manifest with no addressable key.
|
||||
ErrEmptyBucket = errors.New("manifest: empty bucket")
|
||||
ErrEmptyObject = errors.New("manifest: empty object")
|
||||
@@ -71,7 +79,7 @@ type Tx interface {
|
||||
ID() ids.ID
|
||||
// Type returns the transaction type.
|
||||
Type() TxType
|
||||
// Bytes returns the serialized transaction (type byte + JSON body).
|
||||
// Bytes returns the serialized transaction (type byte + ZAP wire object).
|
||||
Bytes() []byte
|
||||
// Verify validates the transaction in isolation (no state access).
|
||||
Verify() error
|
||||
@@ -154,34 +162,34 @@ func (tx *PutManifestTx) Verify() error {
|
||||
// cryptographically verifiable inside the pure, local block apply:
|
||||
//
|
||||
// - Signer — the claimed HRW owner's NodeID. Verify recomputes
|
||||
// pinning.Owner(Range, V@Epoch) and requires Signer == owner.
|
||||
// pinning.Owner(Range, V@Epoch) and requires Signer == owner.
|
||||
// - SignerScheme — the NodeIDScheme byte (0x42 ML-DSA-65 / 0x43 ML-DSA-87) the
|
||||
// NodeID was derived under.
|
||||
// NodeID was derived under.
|
||||
// - SignerPubKey — the signer's ML-DSA public key. Verify RE-DERIVES the NodeID
|
||||
// from this key (NodeID = SHAKE256-384(domain‖chainID‖scheme‖
|
||||
// pubkey)[:20]) and requires it to equal Signer. The NodeID is
|
||||
// thus a binding commitment to the key — a forger cannot supply
|
||||
// a different key for the owner's NodeID (~2^160 second-preimage
|
||||
// on the truncated SHAKE; the same bound the identity system
|
||||
// already rests on).
|
||||
// from this key (NodeID = SHAKE256-384(domain‖chainID‖scheme‖
|
||||
// pubkey)[:20]) and requires it to equal Signer. The NodeID is
|
||||
// thus a binding commitment to the key — a forger cannot supply
|
||||
// a different key for the owner's NodeID (~2^160 second-preimage
|
||||
// on the truncated SHAKE; the same bound the identity system
|
||||
// already rests on).
|
||||
// - Sig — an ML-DSA signature over SigningBytes() (the canonical,
|
||||
// SP 800-185-framed encoding of Range‖Count‖Epoch‖Nonce‖
|
||||
// Fingerprint — NOT over the Sig/Signer/PubKey fields, which
|
||||
// would be circular). Only the holder of the owner's secret key
|
||||
// can produce it.
|
||||
// SP 800-185-framed encoding of Range‖Count‖Epoch‖Nonce‖
|
||||
// Fingerprint — NOT over the Sig/Signer/PubKey fields, which
|
||||
// would be circular). Only the holder of the owner's secret key
|
||||
// can produce it.
|
||||
// - Epoch — the P-Chain height the validator set was frozen at. Verify
|
||||
// requires it to equal the block's epoch so ownership is
|
||||
// resolved against the agreed set, and binding it into the
|
||||
// signature stops cross-epoch replay where ownership differs.
|
||||
// requires it to equal the block's epoch so ownership is
|
||||
// resolved against the agreed set, and binding it into the
|
||||
// signature stops cross-epoch replay where ownership differs.
|
||||
// - Nonce — a per-emission uniquifier (the proposer stamps the block
|
||||
// height) so two allocations of the same Range/Count in
|
||||
// different blocks sign distinct messages.
|
||||
// height) so two allocations of the same Range/Count in
|
||||
// different blocks sign distinct messages.
|
||||
// - Fingerprint — pinning.EpochFingerprint(Epoch, members): the signer's
|
||||
// commitment to the EXACT validator set it pinned against.
|
||||
// Verify recomputes it from its OWN local snapshot and rejects
|
||||
// a mismatch (DESIGN §6.4) — so a proposer that pinned against a
|
||||
// set the verifier does not hold cannot get its block accepted,
|
||||
// and Verify never has to fetch a set over the network.
|
||||
// commitment to the EXACT validator set it pinned against.
|
||||
// Verify recomputes it from its OWN local snapshot and rejects
|
||||
// a mismatch (DESIGN §6.4) — so a proposer that pinned against a
|
||||
// set the verifier does not hold cannot get its block accepted,
|
||||
// and Verify never has to fetch a set over the network.
|
||||
//
|
||||
// An AllocateTx enters the mempool as an UNSIGNED intent (Range+Count only); the
|
||||
// owning proposer stamps Epoch/Nonce/Fingerprint and signs it at BuildBlock with
|
||||
@@ -329,32 +337,23 @@ func leftEncode(x uint64) []byte {
|
||||
// TxParser parses raw transaction bytes off the wire.
|
||||
type TxParser struct{}
|
||||
|
||||
// Parse decodes a transaction from its wire bytes (type byte + JSON body).
|
||||
// Parse decodes a transaction from its wire bytes (type byte + ZAP wire object).
|
||||
// It dispatches on the leading type byte and hands the ZAP body to the concrete
|
||||
// type's parser — one dispatch, one codec.
|
||||
func (p *TxParser) Parse(data []byte) (Tx, error) {
|
||||
if len(data) < 1 {
|
||||
return nil, ErrInvalidTxType
|
||||
}
|
||||
switch TxType(data[0]) {
|
||||
case TxPutManifest:
|
||||
return parse[PutManifestTx](data, TxPutManifest)
|
||||
return parsePutManifest(data)
|
||||
case TxAllocate:
|
||||
return parse[AllocateTx](data, TxAllocate)
|
||||
return parseAllocate(data)
|
||||
default:
|
||||
return nil, ErrInvalidTxType
|
||||
}
|
||||
}
|
||||
|
||||
// parse decodes the JSON body into a concrete tx and stamps its type, wire
|
||||
// bytes, and deterministic TxID. One codec for every type.
|
||||
func parse[T any](data []byte, txType TxType) (*T, error) {
|
||||
tx := new(T)
|
||||
if err := json.Unmarshal(data[1:], tx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stampBase(tx, txType, data)
|
||||
return tx, nil
|
||||
}
|
||||
|
||||
// stampBase sets the embedded BaseTx's type, wire bytes, and checksum TxID. It
|
||||
// relies on every concrete tx embedding BaseTx.
|
||||
func stampBase(tx any, txType TxType, data []byte) {
|
||||
@@ -367,29 +366,213 @@ func stampBase(tx any, txType TxType, data []byte) {
|
||||
}
|
||||
}
|
||||
|
||||
// Marshal serializes a concrete transaction into wire bytes: type byte + JSON
|
||||
// body. The single codec used by both constructors and the parser.
|
||||
func Marshal[T any](tx *T, txType TxType) ([]byte, error) {
|
||||
body, err := json.Marshal(tx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// marshalTx serializes a concrete transaction into wire bytes: type byte + ZAP
|
||||
// wire object. The single codec used by both constructors and the parser. A tx
|
||||
// type with no ZAP encoder is a programmer error (an unhandled type byte would
|
||||
// be undecodable), so it panics rather than returning a swallowed error.
|
||||
func marshalTx(tx any, txType TxType) []byte {
|
||||
var body []byte
|
||||
switch v := tx.(type) {
|
||||
case *PutManifestTx:
|
||||
body = marshalPutManifest(v)
|
||||
case *AllocateTx:
|
||||
body = marshalAllocate(v)
|
||||
default:
|
||||
panic(fmt.Sprintf("txs: cannot marshal unknown transaction type %T", tx))
|
||||
}
|
||||
out := make([]byte, 1+len(body))
|
||||
out[0] = byte(txType)
|
||||
copy(out[1:], body)
|
||||
return out, nil
|
||||
return out
|
||||
}
|
||||
|
||||
// finalize serializes the constructed transaction and stamps its wire bytes and
|
||||
// deterministic TxID, so a freshly built tx is immediately wire-ready and
|
||||
// Parse-round-trippable. JSON encoding of these plain structs cannot fail; a
|
||||
// failure is a programmer error, not a recoverable condition.
|
||||
// Parse-round-trippable. ZAP encoding of these plain structs cannot fail; an
|
||||
// unknown type is a programmer error, not a recoverable condition.
|
||||
func finalize[T any](tx *T, base *BaseTx) *T {
|
||||
wire, err := Marshal(tx, base.TxType)
|
||||
if err != nil {
|
||||
panic("txs: failed to marshal transaction: " + err.Error())
|
||||
}
|
||||
wire := marshalTx(tx, base.TxType)
|
||||
base.TxID = ids.Checksum256(wire)
|
||||
base.bytes = wire
|
||||
return tx
|
||||
}
|
||||
|
||||
// ---- PutManifestTx wire object ----
|
||||
//
|
||||
// Bucket bytes @ 0
|
||||
// Object bytes @ 8
|
||||
// FileLens list @ 16 (u32 per FileID string length)
|
||||
// FileBlob bytes @ 24 (concatenated FileID strings)
|
||||
// Size i64 @ 32
|
||||
// ETag bytes @ 40
|
||||
const (
|
||||
pmBucket = 0
|
||||
pmObject = 8
|
||||
pmFileLens = 16
|
||||
pmFileBlob = 24
|
||||
pmSize = 32
|
||||
pmETag = 40
|
||||
pmObjSize = 48
|
||||
)
|
||||
|
||||
func marshalPutManifest(tx *PutManifestTx) []byte {
|
||||
fileLens, fileBlob := packStrings(tx.FileIDs)
|
||||
b := zap.NewBuilder(zap.HeaderSize + pmObjSize + len(tx.Bucket) + len(tx.Object) +
|
||||
len(fileBlob) + 4*len(fileLens) + len(tx.ETag) + 64)
|
||||
fileLensOff := writeU32List(b, fileLens)
|
||||
|
||||
ob := b.StartObject(pmObjSize)
|
||||
ob.SetBytes(pmBucket, []byte(tx.Bucket))
|
||||
ob.SetBytes(pmObject, []byte(tx.Object))
|
||||
ob.SetList(pmFileLens, fileLensOff, len(fileLens))
|
||||
ob.SetBytes(pmFileBlob, fileBlob)
|
||||
ob.SetInt64(pmSize, tx.Size)
|
||||
ob.SetBytes(pmETag, []byte(tx.ETag))
|
||||
ob.FinishAsRoot()
|
||||
return b.Finish()
|
||||
}
|
||||
|
||||
func parsePutManifest(data []byte) (*PutManifestTx, error) {
|
||||
msg, err := zap.Parse(data[1:])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if msg.Size() != len(data)-1 {
|
||||
return nil, ErrTrailingBytes
|
||||
}
|
||||
o := msg.Root()
|
||||
tx := &PutManifestTx{
|
||||
Bucket: string(o.Bytes(pmBucket)),
|
||||
Object: string(o.Bytes(pmObject)),
|
||||
FileIDs: unpackStrings(readU32List(o, pmFileLens), o.Bytes(pmFileBlob)),
|
||||
Size: o.Int64(pmSize),
|
||||
ETag: string(o.Bytes(pmETag)),
|
||||
}
|
||||
stampBase(tx, TxPutManifest, data)
|
||||
return tx, nil
|
||||
}
|
||||
|
||||
// ---- AllocateTx wire object ----
|
||||
//
|
||||
// Range bytes @ 0
|
||||
// Count u32 @ 8
|
||||
// SignerScheme u8 @ 12
|
||||
// Epoch u64 @ 16
|
||||
// Nonce u64 @ 24
|
||||
// Fingerprint 32B @ 32 (ids.ID, in place)
|
||||
// Signer 20B @ 64 (ids.NodeID, in place)
|
||||
// SignerPubKey bytes @ 88
|
||||
// Sig bytes @ 96
|
||||
const (
|
||||
alRange = 0
|
||||
alCount = 8
|
||||
alScheme = 12
|
||||
alEpoch = 16
|
||||
alNonce = 24
|
||||
alFinger = 32
|
||||
alSigner = 64
|
||||
alPubKey = 88
|
||||
alSig = 96
|
||||
alObjSize = 104
|
||||
)
|
||||
|
||||
func marshalAllocate(tx *AllocateTx) []byte {
|
||||
b := zap.NewBuilder(zap.HeaderSize + alObjSize + len(tx.Range) +
|
||||
len(tx.SignerPubKey) + len(tx.Sig) + 64)
|
||||
ob := b.StartObject(alObjSize)
|
||||
ob.SetBytes(alRange, []byte(tx.Range))
|
||||
ob.SetUint32(alCount, tx.Count)
|
||||
ob.SetUint8(alScheme, tx.SignerScheme)
|
||||
ob.SetUint64(alEpoch, tx.Epoch)
|
||||
ob.SetUint64(alNonce, tx.Nonce)
|
||||
ob.SetBytesFixed(alFinger, tx.Fingerprint[:])
|
||||
ob.SetBytesFixed(alSigner, tx.Signer[:])
|
||||
ob.SetBytes(alPubKey, tx.SignerPubKey)
|
||||
ob.SetBytes(alSig, tx.Sig)
|
||||
ob.FinishAsRoot()
|
||||
return b.Finish()
|
||||
}
|
||||
|
||||
func parseAllocate(data []byte) (*AllocateTx, error) {
|
||||
msg, err := zap.Parse(data[1:])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if msg.Size() != len(data)-1 {
|
||||
return nil, ErrTrailingBytes
|
||||
}
|
||||
o := msg.Root()
|
||||
tx := &AllocateTx{
|
||||
Range: string(o.Bytes(alRange)),
|
||||
Count: o.Uint32(alCount),
|
||||
Epoch: o.Uint64(alEpoch),
|
||||
Nonce: o.Uint64(alNonce),
|
||||
SignerScheme: o.Uint8(alScheme),
|
||||
SignerPubKey: appendBytes(o.Bytes(alPubKey)),
|
||||
Sig: appendBytes(o.Bytes(alSig)),
|
||||
}
|
||||
copy(tx.Fingerprint[:], o.BytesFixedSlice(alFinger, ids.IDLen))
|
||||
copy(tx.Signer[:], o.BytesFixedSlice(alSigner, ids.NodeIDLen))
|
||||
stampBase(tx, TxAllocate, data)
|
||||
return tx, nil
|
||||
}
|
||||
|
||||
// ---- shared ZAP helpers (mirror chains/mpcvm + chains/bridgevm) ----
|
||||
|
||||
// writeU32List tails a u32 list into the builder and returns its offset.
|
||||
func writeU32List(b *zap.Builder, xs []uint32) int {
|
||||
lb := b.StartList(4)
|
||||
for _, x := range xs {
|
||||
lb.AddUint32(x)
|
||||
}
|
||||
off, _ := lb.Finish()
|
||||
return off
|
||||
}
|
||||
|
||||
// readU32List reads the u32 list at ptrOff.
|
||||
func readU32List(o zap.Object, ptrOff int) []uint32 {
|
||||
l := o.ListStride(ptrOff, 4)
|
||||
n := l.Len()
|
||||
out := make([]uint32, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out[i] = l.Uint32(i)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// packStrings returns per-string byte lengths and the concatenated blob.
|
||||
func packStrings(ss []string) ([]uint32, []byte) {
|
||||
lens := make([]uint32, len(ss))
|
||||
var blob []byte
|
||||
for i, s := range ss {
|
||||
lens[i] = uint32(len(s))
|
||||
blob = append(blob, s...)
|
||||
}
|
||||
return lens, blob
|
||||
}
|
||||
|
||||
// unpackStrings re-splits blob by lens. Returns nil (not empty) for no entries.
|
||||
func unpackStrings(lens []uint32, blob []byte) []string {
|
||||
if len(lens) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(lens))
|
||||
pos := 0
|
||||
for _, l := range lens {
|
||||
if pos+int(l) > len(blob) {
|
||||
break
|
||||
}
|
||||
out = append(out, string(blob[pos:pos+int(l)]))
|
||||
pos += int(l)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// appendBytes returns a defensive copy of b (nil for empty), so a parsed tx does
|
||||
// not alias the caller's wire buffer.
|
||||
func appendBytes(b []byte) []byte {
|
||||
if len(b) == 0 {
|
||||
return nil
|
||||
}
|
||||
return append([]byte(nil), b...)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
// wire_test.go — the native-ZAP wire-codec proof for every S-Chain tx type. Each
|
||||
// test builds a tx, serializes it through the ZAP codec, parses it back, and
|
||||
// asserts EVERY field survives byte-for-byte, that the wire is stable through a
|
||||
// round trip (parsed.Bytes() == orig.Bytes()), that the TxID is a deterministic
|
||||
// pure function of the fields, and that the parser rejects trailing bytes. This
|
||||
// is the >1-validator safety property: the same logical tx always yields the
|
||||
// same wire and therefore the same content-addressed id on every node.
|
||||
package txs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/ids"
|
||||
)
|
||||
|
||||
// TestPutManifestWireRoundTrip proves a PutManifestTx round-trips through the ZAP
|
||||
// codec with every field intact and a stable wire/id.
|
||||
func TestPutManifestWireRoundTrip(t *testing.T) {
|
||||
orig := NewPutManifestTx(
|
||||
"bucket-A",
|
||||
"path/to/object.bin",
|
||||
[]string{"3,01f2a", "3,02b7c", "4,0099"},
|
||||
1<<40, // 1 TiB — exercises the full int64 range, not a small value
|
||||
"9bec6f3c1e5d4a2b8f0e7c6d5a4b3c2d",
|
||||
)
|
||||
|
||||
parser := &TxParser{}
|
||||
parsed, err := parser.Parse(orig.Bytes())
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if parsed.Type() != TxPutManifest {
|
||||
t.Fatalf("parsed type = %v, want TxPutManifest", parsed.Type())
|
||||
}
|
||||
pm, ok := parsed.(*PutManifestTx)
|
||||
if !ok {
|
||||
t.Fatalf("parsed concrete type = %T, want *PutManifestTx", parsed)
|
||||
}
|
||||
|
||||
if pm.Bucket != orig.Bucket {
|
||||
t.Errorf("Bucket = %q, want %q", pm.Bucket, orig.Bucket)
|
||||
}
|
||||
if pm.Object != orig.Object {
|
||||
t.Errorf("Object = %q, want %q", pm.Object, orig.Object)
|
||||
}
|
||||
if len(pm.FileIDs) != len(orig.FileIDs) {
|
||||
t.Fatalf("FileIDs len = %d, want %d", len(pm.FileIDs), len(orig.FileIDs))
|
||||
}
|
||||
for i := range orig.FileIDs {
|
||||
if pm.FileIDs[i] != orig.FileIDs[i] {
|
||||
t.Errorf("FileIDs[%d] = %q, want %q", i, pm.FileIDs[i], orig.FileIDs[i])
|
||||
}
|
||||
}
|
||||
if pm.Size != orig.Size {
|
||||
t.Errorf("Size = %d, want %d", pm.Size, orig.Size)
|
||||
}
|
||||
if pm.ETag != orig.ETag {
|
||||
t.Errorf("ETag = %q, want %q", pm.ETag, orig.ETag)
|
||||
}
|
||||
if pm.ID() != orig.ID() {
|
||||
t.Errorf("TxID = %s, want %s", pm.ID(), orig.ID())
|
||||
}
|
||||
if !bytes.Equal(pm.Bytes(), orig.Bytes()) {
|
||||
t.Errorf("wire not stable through round trip")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPutManifestSingleFile proves the smallest legal manifest (one file blob)
|
||||
// round-trips — the boundary Verify requires (ErrNoFileIDs below it).
|
||||
func TestPutManifestSingleFile(t *testing.T) {
|
||||
orig := NewPutManifestTx("b", "o", []string{"1,0"}, 0, "")
|
||||
parsed, err := (&TxParser{}).Parse(orig.Bytes())
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
pm := parsed.(*PutManifestTx)
|
||||
if len(pm.FileIDs) != 1 || pm.FileIDs[0] != "1,0" {
|
||||
t.Fatalf("FileIDs = %v, want [1,0]", pm.FileIDs)
|
||||
}
|
||||
if pm.Bucket != "b" || pm.Object != "o" || pm.Size != 0 || pm.ETag != "" {
|
||||
t.Fatalf("field mismatch: %+v", pm)
|
||||
}
|
||||
if pm.ID() != orig.ID() || !bytes.Equal(pm.Bytes(), orig.Bytes()) {
|
||||
t.Fatalf("id/wire not stable")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPutManifestDeterministicID proves the id is a pure function of the fields:
|
||||
// two independently-built PutManifestTxs with identical fields share wire+id, and
|
||||
// changing any field moves the id.
|
||||
func TestPutManifestDeterministicID(t *testing.T) {
|
||||
mk := func() *PutManifestTx {
|
||||
return NewPutManifestTx("b", "o", []string{"1,0", "2,1"}, 42, "etag")
|
||||
}
|
||||
a, b := mk(), mk()
|
||||
if a.ID() != b.ID() {
|
||||
t.Fatalf("identical PutManifest have different ids: %s != %s", a.ID(), b.ID())
|
||||
}
|
||||
if !bytes.Equal(a.Bytes(), b.Bytes()) {
|
||||
t.Fatalf("identical PutManifest have different wire bytes")
|
||||
}
|
||||
cases := map[string]*PutManifestTx{
|
||||
"bucket": NewPutManifestTx("b2", "o", []string{"1,0", "2,1"}, 42, "etag"),
|
||||
"object": NewPutManifestTx("b", "o2", []string{"1,0", "2,1"}, 42, "etag"),
|
||||
"fileIds": NewPutManifestTx("b", "o", []string{"1,0", "2,2"}, 42, "etag"),
|
||||
"filecnt": NewPutManifestTx("b", "o", []string{"1,0"}, 42, "etag"),
|
||||
"size": NewPutManifestTx("b", "o", []string{"1,0", "2,1"}, 43, "etag"),
|
||||
"etag": NewPutManifestTx("b", "o", []string{"1,0", "2,1"}, 42, "etag2"),
|
||||
}
|
||||
for name, tx := range cases {
|
||||
if tx.ID() == a.ID() {
|
||||
t.Errorf("changing %s did not change the id", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllocateWireRoundTripSigned proves a fully-authorized AllocateTx — the
|
||||
// proposer-stamped Epoch/Nonce/Fingerprint plus the ML-DSA pinned-writer
|
||||
// authorization — round-trips with EVERY field intact. The existing
|
||||
// allocate_test only covers the unsigned intent; this covers the signed image
|
||||
// that actually travels in the block and whose TxID must cover the auth.
|
||||
func TestAllocateWireRoundTripSigned(t *testing.T) {
|
||||
fingerprint := ids.ID{0xfe, 0xed, 0xfa, 0xce, 0x01, 0x02, 0x03, 0x04, 0x05}
|
||||
signer := ids.NodeID{0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x11, 0x22, 0x33, 0x44}
|
||||
pub := bytes.Repeat([]byte{0x9c}, 1952) // ML-DSA-65 public key size
|
||||
sig := bytes.Repeat([]byte{0x7e}, 3309) // ML-DSA-65 signature size
|
||||
|
||||
orig := NewAllocateTx("bucket-A/band-3", 64).WithAuthorization(
|
||||
7, // epoch
|
||||
131, // nonce
|
||||
fingerprint, // set commitment
|
||||
signer, // HRW owner NodeID
|
||||
0x42, // ML-DSA-65 scheme byte
|
||||
pub,
|
||||
sig,
|
||||
)
|
||||
|
||||
parsed, err := (&TxParser{}).Parse(orig.Bytes())
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if parsed.Type() != TxAllocate {
|
||||
t.Fatalf("parsed type = %v, want TxAllocate", parsed.Type())
|
||||
}
|
||||
at, ok := parsed.(*AllocateTx)
|
||||
if !ok {
|
||||
t.Fatalf("parsed concrete type = %T, want *AllocateTx", parsed)
|
||||
}
|
||||
|
||||
if at.Range != orig.Range {
|
||||
t.Errorf("Range = %q, want %q", at.Range, orig.Range)
|
||||
}
|
||||
if at.Count != orig.Count {
|
||||
t.Errorf("Count = %d, want %d", at.Count, orig.Count)
|
||||
}
|
||||
if at.Epoch != orig.Epoch {
|
||||
t.Errorf("Epoch = %d, want %d", at.Epoch, orig.Epoch)
|
||||
}
|
||||
if at.Nonce != orig.Nonce {
|
||||
t.Errorf("Nonce = %d, want %d", at.Nonce, orig.Nonce)
|
||||
}
|
||||
if at.Fingerprint != orig.Fingerprint {
|
||||
t.Errorf("Fingerprint = %s, want %s", at.Fingerprint, orig.Fingerprint)
|
||||
}
|
||||
if at.Signer != orig.Signer {
|
||||
t.Errorf("Signer = %s, want %s", at.Signer, orig.Signer)
|
||||
}
|
||||
if at.SignerScheme != orig.SignerScheme {
|
||||
t.Errorf("SignerScheme = %d, want %d", at.SignerScheme, orig.SignerScheme)
|
||||
}
|
||||
if !bytes.Equal(at.SignerPubKey, orig.SignerPubKey) {
|
||||
t.Errorf("SignerPubKey mismatch (len %d vs %d)", len(at.SignerPubKey), len(orig.SignerPubKey))
|
||||
}
|
||||
if !bytes.Equal(at.Sig, orig.Sig) {
|
||||
t.Errorf("Sig mismatch (len %d vs %d)", len(at.Sig), len(orig.Sig))
|
||||
}
|
||||
if !at.IsSigned() {
|
||||
t.Errorf("IsSigned() = false, want true")
|
||||
}
|
||||
if at.ID() != orig.ID() {
|
||||
t.Errorf("TxID = %s, want %s", at.ID(), orig.ID())
|
||||
}
|
||||
if !bytes.Equal(at.Bytes(), orig.Bytes()) {
|
||||
t.Errorf("wire not stable through round trip")
|
||||
}
|
||||
// SigningBytes is derived purely from the signed fields — it must survive the
|
||||
// round trip too (the block's signature verifies against exactly these bytes).
|
||||
if !bytes.Equal(at.SigningBytes(), orig.SigningBytes()) {
|
||||
t.Errorf("SigningBytes not stable through round trip")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllocateWireRoundTripUnsigned proves the mempool intent (no auth stamped)
|
||||
// round-trips: the zero Fingerprint/Signer and nil pubkey/sig come back intact
|
||||
// and the tx reports itself unsigned.
|
||||
func TestAllocateWireRoundTripUnsigned(t *testing.T) {
|
||||
orig := NewAllocateTx("r/1", 1)
|
||||
parsed, err := (&TxParser{}).Parse(orig.Bytes())
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
at := parsed.(*AllocateTx)
|
||||
if at.Range != "r/1" || at.Count != 1 {
|
||||
t.Fatalf("Range/Count mismatch: %+v", at)
|
||||
}
|
||||
if at.Epoch != 0 || at.Nonce != 0 {
|
||||
t.Errorf("Epoch/Nonce = %d/%d, want 0/0", at.Epoch, at.Nonce)
|
||||
}
|
||||
if at.Fingerprint != (ids.ID{}) {
|
||||
t.Errorf("Fingerprint = %s, want zero", at.Fingerprint)
|
||||
}
|
||||
if at.Signer != (ids.NodeID{}) {
|
||||
t.Errorf("Signer = %s, want zero", at.Signer)
|
||||
}
|
||||
if at.SignerScheme != 0 || at.SignerPubKey != nil || at.Sig != nil {
|
||||
t.Errorf("auth fields non-empty on unsigned intent: scheme=%d pub=%v sig=%v", at.SignerScheme, at.SignerPubKey, at.Sig)
|
||||
}
|
||||
if at.IsSigned() {
|
||||
t.Errorf("IsSigned() = true, want false")
|
||||
}
|
||||
if at.ID() != orig.ID() || !bytes.Equal(at.Bytes(), orig.Bytes()) {
|
||||
t.Errorf("id/wire not stable")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllocateDeterministicSignedID proves a signed AllocateTx's id is a pure
|
||||
// function of ALL its fields — two identical signed txs share wire+id, and moving
|
||||
// any authorization field moves the id.
|
||||
func TestAllocateDeterministicSignedID(t *testing.T) {
|
||||
fp := ids.ID{0x01}
|
||||
nid := ids.NodeID{0x02}
|
||||
pub := []byte{0x03, 0x04}
|
||||
sig := []byte{0x05, 0x06}
|
||||
mk := func() *AllocateTx {
|
||||
return NewAllocateTx("r", 9).WithAuthorization(5, 6, fp, nid, 0x42, pub, sig)
|
||||
}
|
||||
a, b := mk(), mk()
|
||||
if a.ID() != b.ID() {
|
||||
t.Fatalf("identical signed AllocateTx have different ids: %s != %s", a.ID(), b.ID())
|
||||
}
|
||||
if !bytes.Equal(a.Bytes(), b.Bytes()) {
|
||||
t.Fatalf("identical signed AllocateTx have different wire bytes")
|
||||
}
|
||||
cases := map[string]*AllocateTx{
|
||||
"epoch": NewAllocateTx("r", 9).WithAuthorization(6, 6, fp, nid, 0x42, pub, sig),
|
||||
"nonce": NewAllocateTx("r", 9).WithAuthorization(5, 7, fp, nid, 0x42, pub, sig),
|
||||
"finger": NewAllocateTx("r", 9).WithAuthorization(5, 6, ids.ID{0x09}, nid, 0x42, pub, sig),
|
||||
"signer": NewAllocateTx("r", 9).WithAuthorization(5, 6, fp, ids.NodeID{0x09}, 0x42, pub, sig),
|
||||
"scheme": NewAllocateTx("r", 9).WithAuthorization(5, 6, fp, nid, 0x43, pub, sig),
|
||||
"pubkey": NewAllocateTx("r", 9).WithAuthorization(5, 6, fp, nid, 0x42, []byte{0x03, 0x05}, sig),
|
||||
"sig": NewAllocateTx("r", 9).WithAuthorization(5, 6, fp, nid, 0x42, pub, []byte{0x05, 0x07}),
|
||||
}
|
||||
for name, tx := range cases {
|
||||
if tx.ID() == a.ID() {
|
||||
t.Errorf("changing %s did not change the id", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseTrailingBytes proves the codec is canonical: a well-formed wire object
|
||||
// with any extra byte appended is rejected, not silently accepted.
|
||||
func TestParseTrailingBytes(t *testing.T) {
|
||||
for _, tx := range []Tx{
|
||||
NewPutManifestTx("b", "o", []string{"1,0"}, 1, "e"),
|
||||
NewAllocateTx("r", 1),
|
||||
} {
|
||||
bad := append(append([]byte(nil), tx.Bytes()...), 0x00)
|
||||
if _, err := (&TxParser{}).Parse(bad); !errors.Is(err, ErrTrailingBytes) {
|
||||
t.Errorf("%s: trailing byte err = %v, want ErrTrailingBytes", tx.Type(), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseInvalidType proves an unknown leading type byte is rejected and an
|
||||
// empty input does not panic.
|
||||
func TestParseInvalidType(t *testing.T) {
|
||||
if _, err := (&TxParser{}).Parse(nil); !errors.Is(err, ErrInvalidTxType) {
|
||||
t.Errorf("empty input err = %v, want ErrInvalidTxType", err)
|
||||
}
|
||||
if _, err := (&TxParser{}).Parse([]byte{0xff}); !errors.Is(err, ErrInvalidTxType) {
|
||||
t.Errorf("unknown type byte err = %v, want ErrInvalidTxType", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user