mirror of
https://github.com/luxfi/chains.git
synced 2026-07-27 03:39:41 +00:00
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:
+37
-50
@@ -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)
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user