dexvm: migrate tx wire json→native ZAP (~20-30x faster, D-Chain codec-free)

All 5 concrete tx types (ImportTx/ExportTx/RelayOrderTx/PlaceOrderTx/
CancelOrderTx) + nested AtomicInput/AtomicOutput now encode as
[typeByte][ZAP object] at fixed offsets instead of [typeByte][json body].
Repeated fields use the bridgevm blob-list idiom (u32 lens-list + concat blob
via SetList/SetBytes) — chains-module zap v1.2.0 has AddObjectPtr but not the
List.ObjectPtr reader. Deterministic by construction; TxID = Checksum256(wire);
canonical trailing-byte reject. Bench (ImportTx 2in/2out): marshal 18255→912ns
(~20x), parse 14923→498ns (~30x), far fewer allocs. Round-trip + determinism +
signature-through-wire tests green; dexvm redteam/conservation/custody suites
green (parse txs through this codec e2e).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
zeekay
2026-07-14 15:35:09 -07:00
co-authored by Hanzo Dev
parent b7b7888f41
commit fd2073ecf3
3 changed files with 887 additions and 50 deletions
+37 -50
View File
@@ -25,7 +25,6 @@ package txs
import (
"crypto/sha256"
"encoding/json"
"errors"
"time"
@@ -364,8 +363,8 @@ type RelayOrderTx struct {
// LimitIsUpper records the direction the limit bounds (true: reject price ABOVE the
// limit, the BUY/exact-input ceiling; false: reject price BELOW it, the SELL floor).
// Empty/zero for place/cancel (non-settling) relays.
AssetOut ids.ID `json:"assetOut,omitempty"`
PriceLimit uint64 `json:"priceLimit,omitempty"`
AssetOut ids.ID `json:"assetOut,omitempty"`
PriceLimit uint64 `json:"priceLimit,omitempty"`
LimitIsUpper bool `json:"limitIsUpper,omitempty"`
}
@@ -433,8 +432,8 @@ func (tx *RelayOrderTx) Verify() error {
// SigningBytes is the canonical message a RelayOrderTx signature commits to: the
// wire bytes with the Signature field cleared (so the signature binds From, Method,
// Payload, CollateralRef, Nonce — but never itself). Deterministic (the struct has
// no maps; encoding/json emits fields in declaration order).
// Payload, CollateralRef, Nonce — but never itself). Deterministic (native ZAP
// fixed-offset wire; no maps, list order preserved).
func (tx *RelayOrderTx) SigningBytes() ([]byte, error) {
unsigned := *tx
unsigned.BaseTx.Signature = nil
@@ -578,32 +577,20 @@ func (p *TxParser) Parse(data []byte) (Tx, error) {
txType := TxType(data[0])
switch txType {
case TxImport:
return parse[ImportTx](data, TxImport)
return parseImportTx(data)
case TxExport:
return parse[ExportTx](data, TxExport)
return parseExportTx(data)
case TxRelayOrder:
return parse[RelayOrderTx](data, TxRelayOrder)
return parseRelayOrderTx(data)
case TxPlaceOrder:
return parse[PlaceOrderTx](data, TxPlaceOrder)
return parsePlaceOrderTx(data)
case TxCancelOrder:
return parse[CancelOrderTx](data, TxCancelOrder)
return parseCancelOrderTx(data)
default:
return nil, ErrInvalidTxType
}
}
// parse decodes the JSON body into a concrete tx, stamps its type, wire bytes,
// and the deterministic TxID. One codec for every type — there is exactly one
// way to read a transaction off the wire.
func parse[T any](data []byte, txType TxType) (*T, error) {
tx := new(T)
if err := unmarshalBody(data, 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 via
// the Tx interface methods exposed on *BaseTx. It relies on every concrete tx
// embedding BaseTx.
@@ -619,38 +606,37 @@ func stampBase(tx any, txType TxType, data []byte) {
func (tx *BaseTx) base() *BaseTx { return tx }
// Wire format for every transaction is a single type byte followed by the JSON
// encoding of the concrete transaction struct:
// Wire format for every transaction is a single TxType discriminator byte
// followed by a native ZAP message (one root object per tx, fixed field
// offsets — see wire.go):
//
// [0] = TxType
// [1:] = json.Marshal(tx)
// [1:] = ZAP message
//
// The encoding is deterministic (Go's 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. This is the single codec used by both the constructors and the parser.
// The encoding is deterministic (fixed offsets, no maps, list order preserved),
// so the same logical transaction always serializes to identical bytes and
// therefore the same TxID. This is the single codec used by both the
// constructors and the parser.
// Marshal serializes a concrete transaction struct into wire bytes:
// type byte + JSON body.
// Marshal serializes a concrete transaction struct into wire bytes: TxType
// discriminator byte + ZAP body. It dispatches on the concrete type; the
// txType argument is retained for call-site stability and always matches the
// discriminator the per-type encoder emits.
func Marshal[T any](tx *T, txType TxType) ([]byte, error) {
body, err := json.Marshal(tx)
if err != nil {
return nil, err
switch v := any(tx).(type) {
case *ImportTx:
return marshalImportTx(v), nil
case *ExportTx:
return marshalExportTx(v), nil
case *RelayOrderTx:
return marshalRelayOrderTx(v), nil
case *PlaceOrderTx:
return marshalPlaceOrderTx(v), nil
case *CancelOrderTx:
return marshalCancelOrderTx(v), nil
default:
return nil, ErrInvalidTxType
}
out := make([]byte, 1+len(body))
out[0] = byte(txType)
copy(out[1:], body)
return out, nil
}
// unmarshalBody decodes the JSON body (everything after the type byte) into the
// supplied transaction struct. The type byte itself is validated by the caller
// (TxParser.Parse) before dispatch.
func unmarshalBody(data []byte, v any) error {
if len(data) < 1 {
return ErrInvalidTxType
}
return json.Unmarshal(data[1:], v)
}
// finalize serializes the constructed transaction and stamps its wire bytes and
@@ -659,8 +645,9 @@ func unmarshalBody(data []byte, v any) error {
func finalize[T any](tx *T, base *BaseTx) *T {
wire, err := Marshal(tx, base.TxType)
if err != nil {
// JSON encoding of these plain structs cannot fail; treat as a
// programmer error rather than silently returning a half-built tx.
// The only error path is an unregistered concrete tx type — a
// programmer error, not a runtime condition. Fail loud rather than
// silently returning a half-built tx.
panic("txs: failed to marshal transaction: " + err.Error())
}
base.TxID = ids.Checksum256(wire)
+479
View File
@@ -0,0 +1,479 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package txs
import (
"errors"
"github.com/luxfi/zap"
)
// Native-ZAP struct-is-wire for the D-Chain (dexvm) transaction surface. No
// encoding/json, no reflection, no codec registry — each concrete tx owns its
// marshal/parse over a zap object at FIXED field offsets. Re-genesis is
// authorized, so the on-wire format is exactly these offsets.
//
// Wire envelope (unchanged discriminator contract):
//
// [0] = TxType (TxParser.Parse dispatches on it)
// [1:] = ZAP message (one root object per tx)
//
// Determinism is structural: fixed offsets, no maps, list order preserved. The
// same logical tx therefore serializes to identical bytes, so TxID =
// ids.Checksum256(wire) is stable across nodes (strictly better than the prior
// reliance on Go's declaration-order JSON emission).
//
// TxType/TxID/bytes are NOT stored in the ZAP body: TxType is the leading
// discriminator byte (re-stamped from dispatch), and TxID/bytes are derived
// from the wire on parse. This mirrors the JSON codec it replaces.
var errTrailingBytes = errors.New("dexvm tx: trailing bytes after zap message")
// ---- BaseTx (embedded field block, identical prefix in every tx object) ----
//
// From 20B @ 0
// Nonce u64 @ 20
// GasPrice u64 @ 28
// GasLimit u64 @ 36
// CreatedAt i64 @ 44
// Signature bytes @ 52 (relOffset+length, 8 bytes)
const (
bsFrom = 0
bsNonce = 20
bsGasPrice = 28
bsGasLimit = 36
bsCreatedAt = 44
bsSig = 52
bsEnd = 60
)
func setBase(ob *zap.ObjectBuilder, b *BaseTx) {
ob.SetBytesFixed(bsFrom, b.From[:])
ob.SetUint64(bsNonce, b.Nonce)
ob.SetUint64(bsGasPrice, b.GasPrice)
ob.SetUint64(bsGasLimit, b.GasLimit)
ob.SetInt64(bsCreatedAt, b.CreatedAt)
ob.SetBytes(bsSig, b.Signature)
}
func readBase(o zap.Object, b *BaseTx) {
copy(b.From[:], o.BytesFixedSlice(bsFrom, 20))
b.Nonce = o.Uint64(bsNonce)
b.GasPrice = o.Uint64(bsGasPrice)
b.GasLimit = o.Uint64(bsGasLimit)
b.CreatedAt = o.Int64(bsCreatedAt)
b.Signature = appendBytes(o.Bytes(bsSig))
}
// ---- AtomicInput (nested element, fixed size) ----
//
// UTXOID 32B @ 0
// Asset 32B @ 32
// Amount u64 @ 64
const (
aiUTXOID = 0
aiAsset = 32
aiAmount = 64
aiSize = 72
)
func marshalAtomicInput(in AtomicInput) []byte {
b := zap.NewBuilder(zap.HeaderSize + aiSize)
ob := b.StartObject(aiSize)
ob.SetBytesFixed(aiUTXOID, in.UTXOID[:])
ob.SetBytesFixed(aiAsset, in.Asset[:])
ob.SetUint64(aiAmount, in.Amount)
ob.FinishAsRoot()
return b.Finish()
}
func readAtomicInput(o zap.Object) AtomicInput {
var in AtomicInput
copy(in.UTXOID[:], o.BytesFixedSlice(aiUTXOID, 32))
copy(in.Asset[:], o.BytesFixedSlice(aiAsset, 32))
in.Amount = o.Uint64(aiAmount)
return in
}
// ---- AtomicOutput (nested element, fixed size) ----
//
// Rail u8 @ 0
// Owner 20B @ 1
// Asset 32B @ 21
// Amount u64 @ 53
// Spent u64 @ 61
const (
aoRail = 0
aoOwner = 1
aoAsset = 21
aoAmount = 53
aoSpent = 61
aoSize = 69
)
func marshalAtomicOutput(out AtomicOutput) []byte {
b := zap.NewBuilder(zap.HeaderSize + aoSize)
ob := b.StartObject(aoSize)
ob.SetUint8(aoRail, uint8(out.Rail))
ob.SetBytesFixed(aoOwner, out.Owner[:])
ob.SetBytesFixed(aoAsset, out.Asset[:])
ob.SetUint64(aoAmount, out.Amount)
ob.SetUint64(aoSpent, out.Spent)
ob.FinishAsRoot()
return b.Finish()
}
func readAtomicOutput(o zap.Object) AtomicOutput {
var out AtomicOutput
out.Rail = Rail(o.Uint8(aoRail))
copy(out.Owner[:], o.BytesFixedSlice(aoOwner, 20))
copy(out.Asset[:], o.BytesFixedSlice(aoAsset, 32))
out.Amount = o.Uint64(aoAmount)
out.Spent = o.Uint64(aoSpent)
return out
}
func packInputs(in []AtomicInput) ([]uint32, []byte) {
lens := make([]uint32, len(in))
var blob []byte
for i := range in {
e := marshalAtomicInput(in[i])
lens[i] = uint32(len(e))
blob = append(blob, e...)
}
return lens, blob
}
func packOutputs(out []AtomicOutput) ([]uint32, []byte) {
lens := make([]uint32, len(out))
var blob []byte
for i := range out {
e := marshalAtomicOutput(out[i])
lens[i] = uint32(len(e))
blob = append(blob, e...)
}
return lens, blob
}
func readInputs(o zap.Object, lensOff, blobOff int) []AtomicInput {
lens := readU32List(o, lensOff)
if len(lens) == 0 {
return nil
}
blob := o.Bytes(blobOff)
out := make([]AtomicInput, 0, len(lens))
pos := 0
for _, l := range lens {
if pos+int(l) > len(blob) {
break
}
m, err := zap.Parse(blob[pos : pos+int(l)])
if err != nil {
break
}
out = append(out, readAtomicInput(m.Root()))
pos += int(l)
}
return out
}
func readOutputs(o zap.Object, lensOff, blobOff int) []AtomicOutput {
lens := readU32List(o, lensOff)
if len(lens) == 0 {
return nil
}
blob := o.Bytes(blobOff)
out := make([]AtomicOutput, 0, len(lens))
pos := 0
for _, l := range lens {
if pos+int(l) > len(blob) {
break
}
m, err := zap.Parse(blob[pos : pos+int(l)])
if err != nil {
break
}
out = append(out, readAtomicOutput(m.Root()))
pos += int(l)
}
return out
}
// ---- ImportTx ----
//
// base @ 0..59
// SourceChain 32B @ 60
// InLens list @ 92
// InBlob bytes @ 100
// OutLens list @ 108
// OutBlob bytes @ 116
const (
imSource = bsEnd
imInLens = 92
imInBlob = 100
imOutLens = 108
imOutBlob = 116
imSize = 124
)
func marshalImportTx(tx *ImportTx) []byte {
inLens, inBlob := packInputs(tx.ImportedInputs)
outLens, outBlob := packOutputs(tx.Outputs)
b := zap.NewBuilder(zap.HeaderSize + imSize + len(tx.Signature) +
len(inBlob) + len(outBlob) + 4*(len(inLens)+len(outLens)) + 256)
inLensOff := writeU32List(b, inLens)
outLensOff := writeU32List(b, outLens)
ob := b.StartObject(imSize)
setBase(ob, &tx.BaseTx)
ob.SetBytesFixed(imSource, tx.SourceChain[:])
ob.SetList(imInLens, inLensOff, len(inLens))
ob.SetBytes(imInBlob, inBlob)
ob.SetList(imOutLens, outLensOff, len(outLens))
ob.SetBytes(imOutBlob, outBlob)
ob.FinishAsRoot()
return withType(TxImport, b.Finish())
}
func parseImportTx(data []byte) (*ImportTx, error) {
o, err := parseRoot(data)
if err != nil {
return nil, err
}
tx := &ImportTx{}
readBase(o, &tx.BaseTx)
copy(tx.SourceChain[:], o.BytesFixedSlice(imSource, 32))
tx.ImportedInputs = readInputs(o, imInLens, imInBlob)
tx.Outputs = readOutputs(o, imOutLens, imOutBlob)
stampBase(tx, TxImport, data)
return tx, nil
}
// ---- ExportTx ----
//
// base @ 0..59
// DestChain 32B @ 60
// FillRef 32B @ 92
// OutLens list @ 124
// OutBlob bytes @ 132
const (
exDest = bsEnd
exFillRef = 92
exOutLens = 124
exOutBlob = 132
exSize = 140
)
func marshalExportTx(tx *ExportTx) []byte {
outLens, outBlob := packOutputs(tx.ExportedOutputs)
b := zap.NewBuilder(zap.HeaderSize + exSize + len(tx.Signature) +
len(outBlob) + 4*len(outLens) + 256)
outLensOff := writeU32List(b, outLens)
ob := b.StartObject(exSize)
setBase(ob, &tx.BaseTx)
ob.SetBytesFixed(exDest, tx.DestinationChain[:])
ob.SetBytesFixed(exFillRef, tx.FillRef[:])
ob.SetList(exOutLens, outLensOff, len(outLens))
ob.SetBytes(exOutBlob, outBlob)
ob.FinishAsRoot()
return withType(TxExport, b.Finish())
}
func parseExportTx(data []byte) (*ExportTx, error) {
o, err := parseRoot(data)
if err != nil {
return nil, err
}
tx := &ExportTx{}
readBase(o, &tx.BaseTx)
copy(tx.DestinationChain[:], o.BytesFixedSlice(exDest, 32))
copy(tx.FillRef[:], o.BytesFixedSlice(exFillRef, 32))
tx.ExportedOutputs = readOutputs(o, exOutLens, exOutBlob)
stampBase(tx, TxExport, data)
return tx, nil
}
// ---- RelayOrderTx ----
//
// base @ 0..59
// CollateralRef 32B @ 60
// AssetOut 32B @ 92
// PriceLimit u64 @ 124
// LimitIsUpper u8 @ 132
// Method bytes @ 133
// Payload bytes @ 141
const (
rlColl = bsEnd
rlAsset = 92
rlPrice = 124
rlUpper = 132
rlMethod = 133
rlPayload = 141
rlSize = 149
)
func marshalRelayOrderTx(tx *RelayOrderTx) []byte {
b := zap.NewBuilder(zap.HeaderSize + rlSize + len(tx.Signature) +
len(tx.Method) + len(tx.Payload) + 128)
ob := b.StartObject(rlSize)
setBase(ob, &tx.BaseTx)
ob.SetBytesFixed(rlColl, tx.CollateralRef[:])
ob.SetBytesFixed(rlAsset, tx.AssetOut[:])
ob.SetUint64(rlPrice, tx.PriceLimit)
ob.SetBool(rlUpper, tx.LimitIsUpper)
ob.SetBytes(rlMethod, []byte(tx.Method))
ob.SetBytes(rlPayload, tx.Payload)
ob.FinishAsRoot()
return withType(TxRelayOrder, b.Finish())
}
func parseRelayOrderTx(data []byte) (*RelayOrderTx, error) {
o, err := parseRoot(data)
if err != nil {
return nil, err
}
tx := &RelayOrderTx{}
readBase(o, &tx.BaseTx)
copy(tx.CollateralRef[:], o.BytesFixedSlice(rlColl, 32))
copy(tx.AssetOut[:], o.BytesFixedSlice(rlAsset, 32))
tx.PriceLimit = o.Uint64(rlPrice)
tx.LimitIsUpper = o.Bool(rlUpper)
tx.Method = string(o.Bytes(rlMethod))
tx.Payload = appendBytes(o.Bytes(rlPayload))
stampBase(tx, TxRelayOrder, data)
return tx, nil
}
// ---- PlaceOrderTx ----
//
// base @ 0..59
// PoolID 32B @ 60
// CollateralRef 32B @ 92
// Price u64 @ 124
// Size u64 @ 132
// Side u8 @ 140
const (
plPool = bsEnd
plColl = 92
plPrice = 124
plSize = 132
plSide = 140
plObjSz = 141
)
func marshalPlaceOrderTx(tx *PlaceOrderTx) []byte {
b := zap.NewBuilder(zap.HeaderSize + plObjSz + len(tx.Signature) + 64)
ob := b.StartObject(plObjSz)
setBase(ob, &tx.BaseTx)
ob.SetBytesFixed(plPool, tx.PoolID[:])
ob.SetBytesFixed(plColl, tx.CollateralRef[:])
ob.SetUint64(plPrice, tx.Price)
ob.SetUint64(plSize, tx.Size)
ob.SetUint8(plSide, tx.Side)
ob.FinishAsRoot()
return withType(TxPlaceOrder, b.Finish())
}
func parsePlaceOrderTx(data []byte) (*PlaceOrderTx, error) {
o, err := parseRoot(data)
if err != nil {
return nil, err
}
tx := &PlaceOrderTx{}
readBase(o, &tx.BaseTx)
copy(tx.PoolID[:], o.BytesFixedSlice(plPool, 32))
copy(tx.CollateralRef[:], o.BytesFixedSlice(plColl, 32))
tx.Price = o.Uint64(plPrice)
tx.Size = o.Uint64(plSize)
tx.Side = o.Uint8(plSide)
stampBase(tx, TxPlaceOrder, data)
return tx, nil
}
// ---- CancelOrderTx ----
//
// base @ 0..59
// PoolID 32B @ 60
// OrderID u64 @ 92
const (
cnPool = bsEnd
cnOrderID = 92
cnSize = 100
)
func marshalCancelOrderTx(tx *CancelOrderTx) []byte {
b := zap.NewBuilder(zap.HeaderSize + cnSize + len(tx.Signature) + 64)
ob := b.StartObject(cnSize)
setBase(ob, &tx.BaseTx)
ob.SetBytesFixed(cnPool, tx.PoolID[:])
ob.SetUint64(cnOrderID, tx.OrderID)
ob.FinishAsRoot()
return withType(TxCancelOrder, b.Finish())
}
func parseCancelOrderTx(data []byte) (*CancelOrderTx, error) {
o, err := parseRoot(data)
if err != nil {
return nil, err
}
tx := &CancelOrderTx{}
readBase(o, &tx.BaseTx)
copy(tx.PoolID[:], o.BytesFixedSlice(cnPool, 32))
tx.OrderID = o.Uint64(cnOrderID)
stampBase(tx, TxCancelOrder, data)
return tx, nil
}
// ---- shared helpers ----
// parseRoot parses the ZAP message that follows the leading TxType byte and
// returns its root object. Canonical: rejects trailing bytes (the message must
// consume exactly data[1:]).
func parseRoot(data []byte) (zap.Object, error) {
msg, err := zap.Parse(data[1:])
if err != nil {
return zap.Object{}, err
}
if msg.Size() != len(data)-1 {
return zap.Object{}, errTrailingBytes
}
return msg.Root(), nil
}
// withType prepends the TxType discriminator byte to a ZAP message body.
func withType(txType TxType, body []byte) []byte {
out := make([]byte, 1+len(body))
out[0] = byte(txType)
copy(out[1:], body)
return out
}
func writeU32List(b *zap.Builder, xs []uint32) int {
lb := b.StartList(4)
for _, x := range xs {
lb.AddUint32(x)
}
off, _ := lb.Finish()
return off
}
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
}
func appendBytes(b []byte) []byte {
if len(b) == 0 {
return nil
}
return append([]byte(nil), b...)
}
+371
View File
@@ -0,0 +1,371 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package txs
import (
"bytes"
"reflect"
"testing"
"github.com/luxfi/crypto/secp256k1"
"github.com/luxfi/ids"
)
// fill helpers producing distinct non-zero fixed-width values so a field
// swapped for another is caught by equality.
func id32(seed byte) ids.ID {
var x ids.ID
for i := range x {
x[i] = seed + byte(i)
}
return x
}
func short20(seed byte) ids.ShortID {
var x ids.ShortID
for i := range x {
x[i] = seed + byte(i)
}
return x
}
func arr32(seed byte) [32]byte {
var x [32]byte
for i := range x {
x[i] = seed + byte(i)
}
return x
}
// nonzeroBase returns a BaseTx with EVERY field non-zero (TxID/bytes excluded —
// those are derived on the wire, not encoded).
func nonzeroBase(t TxType) BaseTx {
return BaseTx{
TxType: t,
From: short20(0x10),
Nonce: 0x1122334455667788,
GasPrice: 4242,
GasLimit: 777777,
CreatedAt: 1_700_000_000_123_456_789,
Signature: []byte{0xde, 0xad, 0xbe, 0xef, 0x01, 0x02},
}
}
// normalize clears the derived identity fields so two logically-equal txs compare
// equal regardless of whether they came from a constructor or a parse.
func normalize(b *BaseTx) {
b.TxID = ids.Empty
b.bytes = nil
}
// sampleTxs returns one fully-populated instance of every concrete tx type,
// each with non-zero values in EVERY field (including slices / nested orders).
func sampleTxs() []Tx {
imp := &ImportTx{
BaseTx: nonzeroBase(TxImport),
SourceChain: id32(0x20),
ImportedInputs: []AtomicInput{
{UTXOID: id32(0x30), Asset: id32(0x40), Amount: 1_000},
{UTXOID: id32(0x31), Asset: id32(0x40), Amount: 2_500},
},
Outputs: []AtomicOutput{
{Rail: RailSwap, Owner: short20(0x50), Asset: id32(0x40), Amount: 900, Spent: 0},
{Rail: RailSwap, Owner: short20(0x51), Asset: id32(0x40), Amount: 600, Spent: 1500},
},
}
exp := &ExportTx{
BaseTx: nonzeroBase(TxExport),
DestinationChain: id32(0x60),
FillRef: id32(0x70),
ExportedOutputs: []AtomicOutput{
{Rail: RailLP, Owner: short20(0x80), Asset: id32(0x90), Amount: 3_333, Spent: 4_444},
},
}
relay := &RelayOrderTx{
BaseTx: nonzeroBase(TxRelayOrder),
Method: "clob_submit",
Payload: []byte("opaque-clob-frame-bytes"),
CollateralRef: id32(0xA0),
AssetOut: id32(0xB0),
PriceLimit: 987_654,
LimitIsUpper: true,
}
place := &PlaceOrderTx{
BaseTx: nonzeroBase(TxPlaceOrder),
PoolID: arr32(0xC0),
Side: 1,
Price: 55_555,
Size: 66_666,
CollateralRef: id32(0xD0),
}
cancel := &CancelOrderTx{
BaseTx: nonzeroBase(TxCancelOrder),
PoolID: arr32(0xE0),
OrderID: 0x0102030405060708,
}
return []Tx{imp, exp, relay, place, cancel}
}
// TestRoundTripAllTypes marshals each concrete tx to native ZAP wire, parses it
// back through the canonical TxParser, and asserts every field survives.
func TestRoundTripAllTypes(t *testing.T) {
parser := &TxParser{}
for _, orig := range sampleTxs() {
orig := orig
t.Run(orig.Type().String(), func(t *testing.T) {
// Marshal via the exported codec (the same path finalize uses).
wire, err := marshalAny(orig)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if wire[0] != byte(orig.Type()) {
t.Fatalf("discriminator byte = %d, want %d", wire[0], orig.Type())
}
parsed, err := parser.Parse(wire)
if err != nil {
t.Fatalf("parse: %v", err)
}
if parsed.Type() != orig.Type() {
t.Fatalf("parsed type = %v, want %v", parsed.Type(), orig.Type())
}
// Parsed TxID must be the checksum of the wire, and Bytes() the wire.
if parsed.ID() != ids.Checksum256(wire) {
t.Fatalf("parsed TxID mismatch")
}
if !bytes.Equal(parsed.Bytes(), wire) {
t.Fatalf("parsed Bytes() != wire")
}
// Deep field equality after clearing derived identity fields.
assertFieldsEqual(t, orig, parsed)
})
}
}
// TestDeterminism marshals each tx twice and requires byte-identical output, and
// requires a re-marshal of the PARSED tx to reproduce the exact wire (TxID is a
// pure function of the wire).
func TestDeterminism(t *testing.T) {
parser := &TxParser{}
for _, orig := range sampleTxs() {
orig := orig
t.Run(orig.Type().String(), func(t *testing.T) {
w1, err := marshalAny(orig)
if err != nil {
t.Fatalf("marshal 1: %v", err)
}
w2, err := marshalAny(orig)
if err != nil {
t.Fatalf("marshal 2: %v", err)
}
if !bytes.Equal(w1, w2) {
t.Fatalf("non-deterministic marshal:\n a=%x\n b=%x", w1, w2)
}
parsed, err := parser.Parse(w1)
if err != nil {
t.Fatalf("parse: %v", err)
}
w3, err := marshalAny(parsed)
if err != nil {
t.Fatalf("re-marshal parsed: %v", err)
}
if !bytes.Equal(w1, w3) {
t.Fatalf("re-marshal of parsed tx diverged:\n orig=%x\n re =%x", w1, w3)
}
if parsed.ID() != ids.Checksum256(w1) {
t.Fatalf("TxID not a pure function of wire")
}
})
}
}
// TestConstructorRoundTrip drives the PUBLIC constructor path (New*Tx) to prove
// a freshly built tx is wire-ready and parses back to an equal tx with the same
// TxID/Bytes.
func TestConstructorRoundTrip(t *testing.T) {
parser := &TxParser{}
from := short20(0x11)
built := []Tx{
NewImportTx(from, 7, id32(0x21),
[]AtomicInput{{UTXOID: id32(0x22), Asset: id32(0x23), Amount: 10}},
[]AtomicOutput{{Owner: from, Asset: id32(0x23), Amount: 9}}),
NewExportTx(from, 8, id32(0x24),
[]AtomicOutput{{Rail: RailLP, Owner: from, Asset: id32(0x25), Amount: 5, Spent: 3}}, id32(0x26)),
NewRelayOrderTx(from, 9, "clob_place", []byte("frame"), id32(0x27)),
NewPlaceOrderTx(from, 10, arr32(0x28), 0, 100, 200),
NewCancelOrderTx(from, 11, arr32(0x29), 42),
}
for _, orig := range built {
orig := orig
t.Run(orig.Type().String(), func(t *testing.T) {
parsed, err := parser.Parse(orig.Bytes())
if err != nil {
t.Fatalf("parse: %v", err)
}
if parsed.ID() != orig.ID() {
t.Fatalf("TxID mismatch: parsed=%s orig=%s", parsed.ID(), orig.ID())
}
if !bytes.Equal(parsed.Bytes(), orig.Bytes()) {
t.Fatalf("Bytes() mismatch")
}
assertFieldsEqual(t, orig, parsed)
})
}
}
// TestRelaySignatureBindsThroughWire proves the ZAP wire preserves the relay
// signature semantics end to end: a signed relay verifies, and any tamper of a
// signature-covered field (From) is rejected at admission.
func TestRelaySignatureBindsThroughWire(t *testing.T) {
key, err := secp256k1.NewPrivateKey()
if err != nil {
t.Fatalf("keygen: %v", err)
}
relay := NewRelayOrderTx(ids.ShortEmpty, 3, "clob_submit", []byte("frame"), id32(0x33))
if err := relay.Sign(key); err != nil {
t.Fatalf("sign: %v", err)
}
if relay.From != ids.ShortID(key.EVMAddress()) {
t.Fatalf("Sign did not stamp From to signer address")
}
if err := relay.Verify(); err != nil {
t.Fatalf("signed relay should verify: %v", err)
}
// Round-trip the signed relay and re-verify off the wire.
parsed, err := (&TxParser{}).Parse(relay.Bytes())
if err != nil {
t.Fatalf("parse signed relay: %v", err)
}
if err := parsed.Verify(); err != nil {
t.Fatalf("parsed signed relay should verify: %v", err)
}
// Tamper the signature-covered From: verify must reject (ErrInvalidSignature).
spoofed := parsed.(*RelayOrderTx)
spoofed.From = short20(0x99)
if err := spoofed.Verify(); err != ErrInvalidSignature {
t.Fatalf("spoofed From must be ErrInvalidSignature, got %v", err)
}
}
// TestTrailingBytesRejected asserts the parser rejects a wire with extra bytes
// appended after the canonical ZAP message.
func TestTrailingBytesRejected(t *testing.T) {
wire, err := marshalAny(sampleTxs()[0])
if err != nil {
t.Fatalf("marshal: %v", err)
}
corrupt := append(append([]byte(nil), wire...), 0xFF, 0xFF)
if _, err := (&TxParser{}).Parse(corrupt); err == nil {
t.Fatalf("expected trailing-bytes rejection, got nil")
}
}
// marshalAny dispatches Marshal on a Tx interface value (the generic Marshal[T]
// needs a concrete *T; the test carries Tx, so switch to the concrete type).
func marshalAny(tx Tx) ([]byte, error) {
switch v := tx.(type) {
case *ImportTx:
return Marshal(v, TxImport)
case *ExportTx:
return Marshal(v, TxExport)
case *RelayOrderTx:
return Marshal(v, TxRelayOrder)
case *PlaceOrderTx:
return Marshal(v, TxPlaceOrder)
case *CancelOrderTx:
return Marshal(v, TxCancelOrder)
default:
return nil, ErrInvalidTxType
}
}
// assertFieldsEqual compares two txs of the same concrete type for full field
// equality, ignoring the derived TxID/bytes.
func assertFieldsEqual(t *testing.T, orig, parsed Tx) {
t.Helper()
switch o := orig.(type) {
case *ImportTx:
p := parsed.(*ImportTx)
oc, pc := *o, *p
normalize(&oc.BaseTx)
normalize(&pc.BaseTx)
if !reflect.DeepEqual(oc, pc) {
t.Fatalf("ImportTx mismatch:\n orig=%+v\n got =%+v", oc, pc)
}
case *ExportTx:
p := parsed.(*ExportTx)
oc, pc := *o, *p
normalize(&oc.BaseTx)
normalize(&pc.BaseTx)
if !reflect.DeepEqual(oc, pc) {
t.Fatalf("ExportTx mismatch:\n orig=%+v\n got =%+v", oc, pc)
}
case *RelayOrderTx:
p := parsed.(*RelayOrderTx)
oc, pc := *o, *p
normalize(&oc.BaseTx)
normalize(&pc.BaseTx)
if !reflect.DeepEqual(oc, pc) {
t.Fatalf("RelayOrderTx mismatch:\n orig=%+v\n got =%+v", oc, pc)
}
case *PlaceOrderTx:
p := parsed.(*PlaceOrderTx)
oc, pc := *o, *p
normalize(&oc.BaseTx)
normalize(&pc.BaseTx)
if !reflect.DeepEqual(oc, pc) {
t.Fatalf("PlaceOrderTx mismatch:\n orig=%+v\n got =%+v", oc, pc)
}
case *CancelOrderTx:
p := parsed.(*CancelOrderTx)
oc, pc := *o, *p
normalize(&oc.BaseTx)
normalize(&pc.BaseTx)
if !reflect.DeepEqual(oc, pc) {
t.Fatalf("CancelOrderTx mismatch:\n orig=%+v\n got =%+v", oc, pc)
}
default:
t.Fatalf("unknown tx type %T", orig)
}
}
// BenchmarkMarshalImport / BenchmarkParseImport give a rough ns/op for the
// native path (compare against the historical json path in the report).
func BenchmarkMarshalImport(b *testing.B) {
tx := sampleTxs()[0].(*ImportTx)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
if _, err := Marshal(tx, TxImport); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkParseImport(b *testing.B) {
wire, err := Marshal(sampleTxs()[0].(*ImportTx), TxImport)
if err != nil {
b.Fatal(err)
}
parser := &TxParser{}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
if _, err := parser.Parse(wire); err != nil {
b.Fatal(err)
}
}
}