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:
2026-07-14 15:32:59 -07:00
co-authored by Hanzo Dev
parent ce3cd0b35b
commit cf6e2972c0
2 changed files with 528 additions and 57 deletions
+240 -57
View File
@@ -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...)
}
+288
View File
@@ -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)
}
}