dexvm: delete the D-Chain proxy, keep the asset registry

The proxy built for the canonical D-Chain slot (VMID "dexvm",
mDVT5EWMumBp3LCqvKwuyZQeY1VXr1jvjGNAt8nL4UFiXvqXr) that luxfi/dex cmd/dexd
already fills, and its wire spoke clob_* while the venue registers dex_*.
Installing it produced a node that loaded a VM, passed health, and answered
nothing — a failure that reads as a working chain. Nothing imported it but its
own plugin main, and the release matrix published it as an image on every tag.

dexvm/registry stays: it is the DEX asset registry, imported by the EVM value
wiring and dex-assets-validate, and it holds the Gate-A primitives its own
gate_test.go covers. The deleted enforce.go was a config adapter over it.

internal/slot pins the invariant by AST, so a comment may discuss the slot but
a declaration may not claim it: no package here names the D VMID, none declares
a clob_* method, and the release matrix ships no image for the slot. Runs
unfiltered on every push — a path filter would miss a NEW package claiming an
already-filled slot, which is the case worth catching.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-07 16:32:00 -07:00
co-authored by Hanzo Dev
parent 3e9fd77e98
commit dd3ff1c9b5
49 changed files with 278 additions and 13814 deletions
-1
View File
@@ -1,6 +1,5 @@
[
{"vm": "evm", "image": "ghcr.io/luxfi/chains/evm", "cgo": "1"},
{"vm": "dexvm", "image": "ghcr.io/luxfi/chains/dexvm", "cgo": "0"},
{"vm": "schain", "image": "ghcr.io/luxfi/chains/schain", "cgo": "0"},
{"vm": "aivm", "image": "ghcr.io/luxfi/chains/aivm", "cgo": "0"},
{"vm": "bridgevm", "image": "ghcr.io/luxfi/chains/bridgevm", "cgo": "0"},
+1 -5
View File
@@ -12,9 +12,7 @@ name: CI
# register, AssetID binds the real token address / UTXO assetID, the
# consensus-mode value guard holds, and the startup gate refuses synthetic
# flags / Liquidity / mock / ticker references).
# 2. dexvm Gate-A wiring tests (enforceRealAssetsOnly refuses bad configs
# end to end).
# 3. LIVE validation: for each net, dex-assets-validate proves every manifest
# 2. LIVE validation: for each net, dex-assets-validate proves every manifest
# entry real against that net's public RPC (eth_chainId, C-Chain consensus
# id, code>0 + decimals() for ERC-20, UTXO assetID on X-Chain) and that the
# gate passes.
@@ -70,8 +68,6 @@ jobs:
cache: false
- name: registry structural + gate tests
run: CGO_ENABLED=0 go test -count=1 -race=false ./dexvm/registry/...
- name: dexvm Gate-A wiring tests
run: CGO_ENABLED=0 go test -count=1 -run '^TestGateA' ./dexvm/
live-validation:
needs: registry-tests
+38
View File
@@ -0,0 +1,38 @@
name: slot
# Which VM plugin slots this module may claim.
#
# luxd loads a VM by executing the binary at <PluginDir>/<VMID>, so a slot admits
# exactly one artifact and the VMID alone decides which chain a binary drives. Two
# artifacts built for one slot is a coin flip settled by whichever image wrote the
# file last, and the losing case reads as a healthy node that answers nothing.
#
# This runs unfiltered, on every push. A path filter would scope it to the files
# that are already clean and miss the case it exists to catch: a NEW package
# claiming a slot that is already filled.
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
slot:
runs-on: [lux-build-linux-amd64]
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: false
- run: CGO_ENABLED=0 go test -count=1 -race=false ./internal/slot/
-133
View File
@@ -1,133 +0,0 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package api provides the RPC API for the DEX VM proxy. The proxy holds NO
// canonical DEX state, so this service is a THIN PASS-THROUGH: order reads and
// writes are forwarded to the d-chain over ZAP via the relay client. There are
// NO local orderbook / pool / position / perpetuals / MEV / ADL endpoints —
// those concerns live ONLY on the d-chain.
package api
import (
"context"
"encoding/hex"
"errors"
"fmt"
"net/http"
)
var (
ErrNotBootstrapped = errors.New("DEX proxy not bootstrapped")
ErrInvalidRequest = errors.New("invalid request")
ErrRelayNotAvailable = errors.New("d-chain relay not configured")
)
// Relayer is the relay surface the proxy VM exposes to the API: forward an
// opaque clob_* frame to the d-chain and return the raw response. It is the
// ONLY DEX capability the proxy has — pure transport to the matcher.
type Relayer interface {
Configured() bool
Relay(ctx context.Context, method string, payload []byte) ([]byte, error)
}
// VM is the minimal surface the API service needs from the proxy VM.
type VM interface {
IsBootstrapped() bool
Relay() Relayer
}
// Service provides the RPC API for the DEX VM proxy.
type Service struct {
vm VM
}
// NewService creates a new API service.
func NewService(vm VM) *Service {
return &Service{vm: vm}
}
// ============================================
// Health and Status
// ============================================
// PingArgs is the argument for the Ping API.
type PingArgs struct{}
// PingReply is the reply for the Ping API.
type PingReply struct {
Success bool `json:"success"`
}
// Ping returns a simple health check response.
func (s *Service) Ping(_ *http.Request, _ *PingArgs, reply *PingReply) error {
reply.Success = true
return nil
}
// StatusArgs is the argument for the Status API.
type StatusArgs struct{}
// StatusReply is the reply for the Status API.
type StatusReply struct {
Bootstrapped bool `json:"bootstrapped"`
RelayConfigured bool `json:"relayConfigured"`
Version string `json:"version"`
}
// Status returns the proxy status.
func (s *Service) Status(_ *http.Request, _ *StatusArgs, reply *StatusReply) error {
reply.Bootstrapped = s.vm.IsBootstrapped()
reply.RelayConfigured = s.vm.Relay().Configured()
reply.Version = "2.0.0"
return nil
}
// ============================================
// Relay pass-through (the only DEX surface)
// ============================================
// RelayArgs forwards an opaque, hex-encoded clob_* frame to the d-chain. The
// proxy does not interpret the payload — the d-chain matcher is the single
// source of truth. Method MUST be one of the frozen clob_* names.
type RelayArgs struct {
Method string `json:"method"`
Payload string `json:"payload"` // hex-encoded ZAP frame
}
// RelayReply returns the d-chain's raw response (hex-encoded).
type RelayReply struct {
Response string `json:"response"`
}
// Relay forwards a clob_* frame to the d-chain and returns its response. This
// is the proxy's entire DEX API: it neither matches nor reads a local book.
func (s *Service) Relay(r *http.Request, args *RelayArgs, reply *RelayReply) error {
if !s.vm.IsBootstrapped() {
return ErrNotBootstrapped
}
if args.Method == "" {
return fmt.Errorf("%w: method required", ErrInvalidRequest)
}
relayer := s.vm.Relay()
if !relayer.Configured() {
return ErrRelayNotAvailable
}
payload, err := hex.DecodeString(args.Payload)
if err != nil {
return fmt.Errorf("%w: payload must be hex", ErrInvalidRequest)
}
resp, err := relayer.Relay(reqCtx(r), args.Method, payload)
if err != nil {
return err
}
reply.Response = hex.EncodeToString(resp)
return nil
}
// reqCtx extracts the request context (or a background context if absent).
func reqCtx(r *http.Request) context.Context {
if r != nil && r.Context() != nil {
return r.Context()
}
return context.Background()
}
-457
View File
@@ -1,457 +0,0 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dexvm
import (
"encoding/binary"
"fmt"
"hash"
"math/big"
"sort"
"github.com/luxfi/database"
"github.com/luxfi/ids"
"github.com/luxfi/vm/chains/atomic"
"github.com/luxfi/chains/dexvm/txs"
)
// atomic.go — the VALUE SETTLEMENT leg of the stateless atomic ZAP proxy.
//
// This is the ONLY primitive in the proxy that moves value between two chains
// in a single atomic commit, modeled byte-for-byte on the platformvm Import/
// Export executors (node/vms/platformvm/txs/executor/standard_tx_executor.go
// ImportTx/ExportTx) and committed at block accept via atomic.SharedMemory.Apply
// (the acceptor.go:103-110 pattern). It is the X<->C shared-memory import/export
// the proxy was specced to do.
//
// CONSERVATION ORDERING (non-negotiable): import (atomic C-Chain debit into the
// proxy) -> ZAP submit to the d-chain (relay.go) -> export (atomic C-Chain
// credit) derived ONLY from the d-chain's returned fills; unfilled IOC remainder
// refunded via the export leg. The proxy NEVER mints — every exported element's
// value is value already locked by an import or already returned as a confirmed
// fill.
// atomicRequests is the per-block accumulation of cross-chain operations to
// apply atomically with the state batch at accept time.
type atomicRequests struct {
// reqs maps a peer chainID to its remove (import) / put (export) operations.
reqs map[ids.ID]*atomic.Requests
}
func newAtomicRequests() *atomicRequests {
return &atomicRequests{reqs: make(map[ids.ID]*atomic.Requests)}
}
// empty reports whether no cross-chain operations were accumulated.
func (a *atomicRequests) empty() bool { return len(a.reqs) == 0 }
// forChain returns (creating if needed) the Requests for a peer chain.
func (a *atomicRequests) forChain(chainID ids.ID) *atomic.Requests {
r, ok := a.reqs[chainID]
if !ok {
r = &atomic.Requests{}
a.reqs[chainID] = r
}
return r
}
// hashInto folds a deterministic commitment of the accumulated cross-chain
// operations into h. The atomic requests are the block's OTHER mutated output
// (alongside the persisted state) — the import UTXO removes and the export UTXO
// puts that move value across chains. Binding them into the StateRoot is what
// makes a block whose settlement legs differ (one node exported a settle, a peer
// whose relay failed exported nothing) produce a different root, even when its
// state keys and block hash match.
//
// Determinism: the per-chain map is walked in sorted chainID order (Go map
// iteration is randomized). Within a chain the RemoveRequests and PutRequests
// keep their accumulation order — that order is itself deterministic (txs are
// processed in block order). Every variable-length field is length-prefixed so
// no two distinct request sets can collide by concatenation.
func (a *atomicRequests) hashInto(h hash.Hash) {
chainIDs := make([]ids.ID, 0, len(a.reqs))
for id := range a.reqs {
chainIDs = append(chainIDs, id)
}
sort.Slice(chainIDs, func(i, j int) bool { return chainIDs[i].Compare(chainIDs[j]) < 0 })
var lenBuf [8]byte
writeChunk := func(b []byte) {
binary.BigEndian.PutUint64(lenBuf[:], uint64(len(b)))
h.Write(lenBuf[:])
h.Write(b)
}
for _, id := range chainIDs {
req := a.reqs[id]
idCopy := id
h.Write(idCopy[:])
binary.BigEndian.PutUint64(lenBuf[:], uint64(len(req.RemoveRequests)))
h.Write(lenBuf[:])
for _, rm := range req.RemoveRequests {
writeChunk(rm)
}
binary.BigEndian.PutUint64(lenBuf[:], uint64(len(req.PutRequests)))
h.Write(lenBuf[:])
for _, e := range req.PutRequests {
writeChunk(e.Key)
writeChunk(e.Value)
binary.BigEndian.PutUint64(lenBuf[:], uint64(len(e.Traits)))
h.Write(lenBuf[:])
for _, tr := range e.Traits {
writeChunk(tr)
}
}
}
}
// executeImport applies an ImportTx: it claims value exported from the source
// chain by consuming its UTXOs from shared memory. The consumed-UTXO set
// guarantees each exported UTXO is claimable exactly once (no double-import).
// The RemoveRequests are accumulated and committed atomically at accept.
//
// Mirrors platformvm standard_tx_executor.go:285 ImportTx — Consume the source
// UTXOs via a shared-memory RemoveRequest keyed by the source chain.
//
// ASSET BIND (the native-aliasing fix): an import credits the ledger with the
// asset of the UTXO it ACTUALLY consumes, never an asset it merely declares. The
// consumed UTXO's recorded value (owner|asset|amount, written by the export side
// via encodeExportedOutput) is read back from shared memory and is authoritative:
// the declared input asset/amount MUST equal the recorded asset/amount, every
// consumed UTXO must be the SAME asset, and every credited output must name that
// asset. Composed with ImportTx.Verify (output.Asset == input.Asset), the credit
// is provably the consumed asset — so a bogus-token UTXO cannot be imported as
// native value. (When the runtime has no shared memory — single-chain test mode —
// there is no real cross-chain UTXO to read or alias; the structural Verify bind
// still holds.)
func (vm *VM) executeImport(tx *txs.ImportTx, ar *atomicRequests) error {
if err := tx.Verify(); err != nil {
return fmt.Errorf("import verify: %w", err)
}
sm := vm.sharedMemory()
var (
importedAsset ids.ID
importedOwner ids.ShortID
importedRail txs.Rail
haveAsset bool
)
// PASS 1 — VALIDATE every input + output with NO state mutation. The import is
// atomic: a rejection on ANY input/output bind must leave the consumed-set
// UNTOUCHED, so MarkConsumed is deferred to pass 2 below. (A bind failure after
// MarkConsumed would burn the UTXO on a rejected import, blocking the rightful
// owner's later claim — the all-or-nothing discipline the conservation invariant
// requires.)
utxoIDs := make([][]byte, 0, len(tx.ImportedInputs))
for _, in := range tx.ImportedInputs {
// Double-spend guard: a UTXO already consumed by a prior import cannot
// be claimed again. This is the proxy's replay protection for the
// atomic-value leg (the conservation invariant — the proxy never mints).
consumed, err := vm.state.IsConsumed(in.UTXOID)
if err != nil {
return fmt.Errorf("import: consumed check: %w", err)
}
if consumed {
return fmt.Errorf("import: %w: %s", errUTXOAlreadyImported, in.UTXOID)
}
// Bind the declared input to the UTXO the chain ACTUALLY holds. The remove
// has not been applied yet (it commits at accept), so the recorded value is
// still readable here. A UTXO that does not exist in shared memory errors —
// the proxy never credits an unbacked input.
if sm != nil {
id := in.UTXOID
vals, gerr := sm.Get(tx.SourceChain, [][]byte{id[:]})
if gerr != nil {
return fmt.Errorf("import: read source UTXO %s: %w", in.UTXOID, gerr)
}
recRail, recOwner, recAsset, recAmount, _, ok := decodeExportedOutput(vals[0])
if !ok {
return fmt.Errorf("import: UTXO %s: %w", in.UTXOID, errImportUTXOValueMalformed)
}
if in.Asset != recAsset {
return fmt.Errorf("import: UTXO %s: %w (declared %s, recorded %s)", in.UTXOID, errImportAssetMismatch, in.Asset, recAsset)
}
if in.Amount != recAmount {
return fmt.Errorf("import: UTXO %s: %w (declared %d, recorded %d)", in.UTXOID, errImportAmountMismatch, in.Amount, recAmount)
}
if !haveAsset {
importedAsset = recAsset
importedOwner = recOwner
importedRail = recRail
haveAsset = true
} else {
if importedAsset != recAsset {
return fmt.Errorf("import: %w", errImportMixedAssets)
}
// Every consumed UTXO must share ONE recorded owner: a deposit credits
// one (owner,asset) ledger row, and mixing owners would let one UTXO's
// owner authorize spending another's value.
if importedOwner != recOwner {
return fmt.Errorf("import: %w (UTXO %s owner %s != %s)", errImportWrongOwner, in.UTXOID, recOwner, importedOwner)
}
// Every consumed UTXO must share ONE recorded rail (lane): an import
// funds a single lane, and mixing lanes would let a swap object and an LP
// object be claimed in one credit (the cross-rail consume H1 closes).
if importedRail != recRail {
return fmt.Errorf("import: %w (UTXO %s rail %d != %d)", errImportMixedRails, in.UTXOID, recRail, importedRail)
}
}
}
id := in.UTXOID
utxoIDs = append(utxoIDs, id[:])
}
// Every credited output must be denominated in the asset actually consumed AND
// owned by the consumed UTXO's recorded owner — the authoritative half of the
// native-aliasing bind (asset axis; the structural output==input half is also
// pinned in ImportTx.Verify) and the owner-aliasing bind (owner axis; an attacker
// must not consume a victim's exported UTXO and credit it to their own account).
// Skipped only when no shared memory was read (single-chain test mode), where
// Verify's structural bind already holds (and there is no real cross-chain UTXO
// to alias).
if haveAsset {
for _, o := range tx.Outputs {
if o.Asset != importedAsset {
return fmt.Errorf("import: %w (output %s, consumed %s)", errImportOutputAsset, o.Asset, importedAsset)
}
if o.Owner != importedOwner {
return fmt.Errorf("import: %w (output owner %s, consumed %s)", errImportWrongOwner, o.Owner, importedOwner)
}
// Authoritative rail bind: every credited output must be on the SAME lane
// the consumed UTXO recorded — so an import cannot re-lane value (credit a
// swap object onto the LP lane or vice-versa). Composed with Verify's
// structural output-rail-uniformity, the credit is provably the consumed
// object's rail (the H1 fix, owner/asset/rail all pinned to the record).
if o.Rail != importedRail {
return fmt.Errorf("import: %w (output rail %d, consumed %d)", errImportMixedRails, o.Rail, importedRail)
}
}
}
// PASS 2 — COMMIT: all binds passed, so mark every consumed UTXO now (the import
// is accepted as a whole). After this point the import cannot fail.
for _, in := range tx.ImportedInputs {
if err := vm.state.MarkConsumed(in.UTXOID); err != nil {
return fmt.Errorf("import: mark consumed: %w", err)
}
}
// Accumulate the atomic remove against the source chain. Applied with the
// state batch at accept (sm.Apply), exactly as platformvm does.
req := ar.forChain(tx.SourceChain)
req.RemoveRequests = append(req.RemoveRequests, utxoIDs...)
// Record the locked collateral so the settle leg can refund whatever the
// d-chain does NOT fill (value conservation: value_in == proceeds + refund).
// Keyed by the import's collateral ref — its first imported UTXO id, the same
// id a relay binds to via RelayOrderTx.CollateralRef. The locked amount is
// the credited output total; its asset is the locked asset. An import with no
// credited outputs (pure fee burn) locks nothing to refund.
//
// The escrow's OWNER is the AUTHENTICATED owner of the consumed C->D object —
// importedOwner, read back from shared memory and bound to every credited output
// above. This is the CRITICAL escrow-theft fix: settleFromFills derives BOTH the
// settle authority and the proceeds/refund payout target from this recorded
// owner, never from the unauthenticated relay tx sender, so a relay naming a
// victim's collateral ref cannot settle it or redirect its value. When there is
// no shared memory (single-chain test mode) there is no real cross-chain UTXO to
// authenticate against; the structurally-verified credited owner (Outputs[0].Owner,
// pinned uniform with the asset/rail axes in Verify) is the escrow owner.
if len(tx.Outputs) > 0 {
ref := tx.ImportedInputs[0].UTXOID
lockedAsset := tx.Outputs[0].Asset
escrowOwner := importedOwner
if !haveAsset {
escrowOwner = tx.Outputs[0].Owner
}
var locked uint64
for _, o := range tx.Outputs {
locked += o.Amount
}
if err := vm.state.PutEscrow(ref, escrowOwner, lockedAsset, locked); err != nil {
return fmt.Errorf("import: record escrow: %w", err)
}
}
return nil
}
// executeExport applies an ExportTx: it settles proceeds back to the
// destination chain by writing UTXO elements into shared memory. The exported
// amounts MUST have been derived ONLY from confirmed d-chain fills (the caller's
// responsibility — see settleFromFills); this executor enforces the wire shape
// and accumulates the PutRequests for the atomic accept-time commit.
//
// Mirrors platformvm standard_tx_executor.go:378 ExportTx — Produce exported
// UTXO elements (Key=utxoID, Value=utxoBytes, Traits=owner) into a PutRequest
// keyed by the destination chain.
func (vm *VM) executeExport(tx *txs.ExportTx, ar *atomicRequests) error {
if err := tx.Verify(); err != nil {
return fmt.Errorf("export verify: %w", err)
}
txID := tx.ID()
elems := make([]*atomic.Element, 0, len(tx.ExportedOutputs))
for i, out := range tx.ExportedOutputs {
utxoID := deriveUTXOID(txID, uint32(i))
elems = append(elems, &atomic.Element{
Key: utxoID[:],
Value: encodeExportedOutput(out),
// Trait = owner address, so the destination chain can index the UTXO
// by recipient (mirrors lux.Addressable.Addresses()).
Traits: [][]byte{out.Owner[:]},
})
}
req := ar.forChain(tx.DestinationChain)
req.PutRequests = append(req.PutRequests, elems...)
return nil
}
// commitAtomic applies the accumulated cross-chain operations atomically with
// the proxy's state batch — the single commit point (the acceptor.go:103-110
// pattern). When the runtime has no SharedMemory (the no-atomic test harness),
// it falls back to writing the batch directly so value semantics still hold.
func (vm *VM) commitAtomic(ar *atomicRequests, batch database.Batch) error {
sm := vm.sharedMemory()
if sm == nil {
// No-atomic fallback: write the state batch ourselves (the acceptor's
// else-branch). A nil shared memory means single-chain test mode.
if batch != nil {
return batch.Write()
}
return nil
}
if ar.empty() {
// Nothing cross-chain this block; still commit the state batch.
if batch != nil {
return batch.Write()
}
return nil
}
if batch == nil {
return sm.Apply(ar.reqs)
}
return sm.Apply(ar.reqs, batch)
}
// sharedMemory returns the per-chain atomic.SharedMemory handed to the proxy by
// the chain manager (runtime.Runtime.SharedMemory), or nil in the test harness.
func (vm *VM) sharedMemory() atomic.SharedMemory {
if vm.consensusRuntime == nil {
return nil
}
sm := vm.consensusRuntime.GetSharedMemory()
if sm == nil {
return nil
}
// runtime.SharedMemory is a type alias for atomic.SharedMemory.
return sm
}
// ---------------------------------------------------------------------------
// Wire helpers for exported UTXO elements + fill decoding.
// ---------------------------------------------------------------------------
// deriveUTXOID computes a deterministic UTXO id from (txID, outputIndex),
// mirroring lux.UTXOID.InputID() — SHA-256 over txID||index. Keeping it local
// avoids dragging the platformvm codec into the proxy.
func deriveUTXOID(txID ids.ID, index uint32) ids.ID {
var buf [36]byte
copy(buf[0:32], txID[:])
binary.BigEndian.PutUint32(buf[32:36], index)
return ids.ID(idHash(buf[:]))
}
// exportedOutputSize is the fixed shared-memory UTXO value width: rail(1) |
// owner(20) | asset(32) | amount(8) | spent(8). IDENTICAL to the precompile's
// exportedOutputSize9999 — the rail byte (the H1 lane tag) leads the object and the
// trailing spent(8) carries the matched INPUT amount on a swap proceeds leg (0 on every
// other leg) so the C-side ImportSettlement can enforce the taker's recorded slippage
// limit independently of the keeper (the taker-authenticated MEV floor). Both repos
// extend this width in lockstep; native_seam_parity_test pins it.
const exportedOutputSize = 1 + 20 + 32 + 8 + 8
// encodeExportedOutput serializes an AtomicOutput as the shared-memory value:
// rail(1) | owner(20) | asset(32) | amount(8) | spent(8). Fixed-width, deterministic.
// The rail byte is the lane the value travels (RailSwap / RailLP), bound by the
// precompile's matching consume path on the C side; spent is the matched input that
// produced a proceeds output (0 elsewhere), the price-binding witness the proceeds floor
// checks.
func encodeExportedOutput(out txs.AtomicOutput) []byte {
v := make([]byte, exportedOutputSize)
v[0] = byte(out.Rail)
copy(v[1:21], out.Owner[:])
copy(v[21:53], out.Asset[:])
binary.BigEndian.PutUint64(v[53:61], out.Amount)
binary.BigEndian.PutUint64(v[61:69], out.Spent)
return v
}
// decodeExportedOutput is the inverse of encodeExportedOutput: it reads back the
// (rail, owner, asset, amount, spent) a consumed source UTXO RECORDED in shared memory.
// executeImport uses this to bind the credited rail, asset AND owner to the value the
// chain actually holds for that UTXO — not what the importing tx merely declares — so
// a bogus-token UTXO can never be imported as native value (the native-aliasing fix),
// a victim's exported UTXO can never be credited to an attacker's account (the
// owner-aliasing fix), and a cross-rail object can never fund the wrong lane (the H1
// rail fix). spent is meaningful only on a swap proceeds leg (the MEV floor witness);
// import ignores it. ok=false for any value that is not exactly the canonical width, so
// a corrupt/garbage record is never reinterpreted into a credit.
func decodeExportedOutput(v []byte) (rail txs.Rail, owner ids.ShortID, asset ids.ID, amount, spent uint64, ok bool) {
if len(v) != exportedOutputSize {
return 0, ids.ShortEmpty, ids.Empty, 0, 0, false
}
rail = txs.Rail(v[0])
copy(owner[:], v[1:21])
copy(asset[:], v[21:53])
amount = binary.BigEndian.Uint64(v[53:61])
spent = binary.BigEndian.Uint64(v[61:69])
return rail, owner, asset, amount, spent, true
}
// ---------------------------------------------------------------------------
// Exact-integer settlement arithmetic.
//
// Fills cross the ZAP wire as EXACT integers (price is uint64 fixed-point
// ×PriceScale on the matcher's PriceInt grid; size is uint64 atomic base units),
// and on-chain value moves in the same integer asset units. There is therefore NO
// fractional notional to round and — critically — NO value-bearing float→int
// conversion anywhere on the settlement path (that conversion's out-of-range
// result is implementation-defined per the Go spec and differs by CPU
// architecture; carrying and settling exact integers is what killed the cross-arch
// consensus fork). The proxy derives base and quote from the fills with the SAME
// per-fill floor the venue's matcher (quoteUnitsFor) and the precompile's
// fillsToDelta apply, so the proxy moves EXACTLY what the venue moved — byte-for-
// byte on every validator — and the conservation invariant (the proxy never mints)
// is a direct integer identity, not a rounding argument.
// priceScaleBig is PriceScale as a *big.Int (the fixed-point divisor for quote).
var priceScaleBig = new(big.Int).SetUint64(PriceScale)
// quoteUnits returns one fill's quote in exact integers: floor(size × price /
// PriceScale), where size is atomic base units and price is fixed-point ×PriceScale.
// The multiply is done in big.Int (size × price overflows uint64) and floored by
// integer division, mirroring dex v1.14.0's v4BalanceDelta / the matcher's
// quoteUnitsFor. Returned as *big.Int so the caller accumulates without overflow.
func quoteUnits(size, price uint64) *big.Int {
q := new(big.Int).Mul(new(big.Int).SetUint64(size), new(big.Int).SetUint64(price))
return q.Quo(q, priceScaleBig)
}
// settleUint64 narrows an exact aggregate (base or quote, summed in big.Int) to a
// uint64 asset amount, refusing a value the proxy cannot represent exactly rather
// than wrapping it. label names the leg for the error. Negative is impossible
// (every summand is non-negative) but is refused defensively.
func settleUint64(v *big.Int, label string) (uint64, error) {
if v.Sign() < 0 {
return 0, fmt.Errorf("settle: negative %s %s", label, v.String())
}
if !v.IsUint64() {
return 0, fmt.Errorf("settle: %s %s exceeds uint64", label, v.String())
}
return v.Uint64(), nil
}
-258
View File
@@ -1,258 +0,0 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dexvm
import (
"context"
"crypto/sha256"
"time"
"github.com/luxfi/ids"
"github.com/luxfi/vm/chain"
"github.com/luxfi/zap"
)
// Ensure Block implements chain.Block
var _ chain.Block = (*Block)(nil)
// Block represents a DEX VM block that wraps the functional ProcessBlock results.
// It implements the chain.Block interface required for the ChainVM.
type Block struct {
vm *ChainVM
// Block header fields
id ids.ID
parentID ids.ID
height uint64
timestamp time.Time
// Block body - serialized transactions
txs [][]byte
// carriedFills are the d-chain matcher's confirmed fills the PROPOSER obtained
// once at build (chainvm.go BuildBlock -> VM.BuildBlockResult) and serialized
// into the block bytes. Every validator parses them and settles purely from
// them — no validator relays during Verify/Accept (RED finding #9). fillSig is
// the reserved trustless-path attestation (empty today). See carried_fills.go.
//
// CARRYING THESE CHANGES THE BLOCK WIRE FORMAT — a network-upgrade-gated,
// lockstep validator change (a node on the old format cannot parse a new block).
carriedFills []carriedFill
fillSig []byte
// Processing result (populated after verification)
result *BlockResult
// Block status
status Status
}
// Status represents block status
type Status uint8
const (
StatusUnknown Status = iota
StatusProcessing
StatusAccepted
StatusRejected
)
// ID returns the unique identifier for this block
func (b *Block) ID() ids.ID {
return b.id
}
// Parent returns the parent block's ID (alias for ParentID)
func (b *Block) Parent() ids.ID {
return b.parentID
}
// ParentID returns the parent block's ID
func (b *Block) ParentID() ids.ID {
return b.parentID
}
// Height returns the block height
func (b *Block) Height() uint64 {
return b.height
}
// Timestamp returns the block timestamp
func (b *Block) Timestamp() time.Time {
return b.timestamp
}
// Block wire (native ZAP, object offsets; RED finding #9). The block carries the
// proposer's confirmed d-chain fills so every validator settles from bytes
// instead of relaying per-validator:
//
// Height u64 @ 0
// Timestamp i64 @ 8 (UnixNano — sub-second cadence preserved, no floor)
// ParentID 32B @ 16
// TxLens list @ 48 (u32 per tx; ORDER is the DEX fairness invariant)
// TxBlob bytes @ 56 (concatenated raw tx bytes, order preserved)
// Fills bytes @ 64 (carried-fills section: encodeCarriedFills output)
//
// blockID = sha256(Bytes()); the fills ride in the bytes, so the id commits to
// them (a peer cannot swap the proposer's fills and keep the same id). Changing
// this wire is a network-upgrade-gated, lockstep validator change.
const (
blkHeight = 0
blkTime = 8
blkParent = 16
blkTxLens = 48
blkTxBlob = 56
blkFills = 64
blkSize = 72
)
// Bytes returns the serialized block.
func (b *Block) Bytes() []byte {
txLens := make([]uint32, len(b.txs))
var txBlob []byte
for i, tx := range b.txs {
txLens[i] = uint32(len(tx))
txBlob = append(txBlob, tx...)
}
fillsSection := encodeCarriedFills(b.carriedFills, b.fillSig)
bld := zap.NewBuilder(zap.HeaderSize + blkSize + len(txBlob) + 4*len(txLens) + len(fillsSection) + 128)
txLensOff := writeU32List(bld, txLens)
ob := bld.StartObject(blkSize)
ob.SetUint64(blkHeight, b.height)
ob.SetInt64(blkTime, b.timestamp.UnixNano())
ob.SetBytesFixed(blkParent, b.parentID[:])
ob.SetList(blkTxLens, txLensOff, len(txLens))
ob.SetBytes(blkTxBlob, txBlob)
ob.SetBytes(blkFills, fillsSection)
ob.FinishAsRoot()
return bld.Finish()
}
// Verify verifies the block is valid by processing it deterministically, then
// attaches the block-CARRIED fills (RED #9) so accept settles from bytes rather
// than relaying. Verify performs NO d-chain I/O on any node — the proposer already
// relayed once at build (BuildBlockResult) and the fills travel in the block bytes.
func (b *Block) Verify(ctx context.Context) error {
result, err := b.vm.inner.ProcessBlock(ctx, b.height, b.timestamp, b.txs)
if err != nil {
return err
}
// Settle from the carried fills (parsed from the block bytes on a validator, or
// produced at build on the proposer). settleCarried (at accept) drives the
// settlement off result.relays (the deterministic plan) and these carried fills.
result.carriedFills = b.carriedFills
result.fillSig = b.fillSig
b.result = result
b.status = StatusProcessing
return nil
}
// Accept marks the block as accepted and commits the proxy's state batch
// ATOMICALLY with the cross-chain shared-memory operations accumulated during
// Verify (the settlement leg) — the single commit point.
func (b *Block) Accept(ctx context.Context) error {
b.status = StatusAccepted
// Update VM state
b.vm.lastAcceptedID = b.id
b.vm.lastAcceptedHeight = b.height
b.vm.blocks[b.id] = b
// Atomic commit: run the deferred relay plan (the irreversible d-chain leg)
// then commit the state batch + shared-memory import/export requests in one
// atomic apply. This is the single commit point — the relay never fires
// during Verify, so a Rejected block never strands a d-chain match.
return b.vm.inner.acceptBlock(ctx, b.result)
}
// Reject marks the block as rejected
func (b *Block) Reject(ctx context.Context) error {
b.status = StatusRejected
// Abort any pending database changes
if b.vm.inner.db != nil {
b.vm.inner.db.Abort()
}
return nil
}
// Status returns the block's status as uint8
func (b *Block) Status() uint8 {
return uint8(b.status)
}
// parseBlock deserializes a block from bytes (the inverse of Block.Bytes). It
// parses the header, the txCount-delimited transactions, and the carried-fills
// section (RED #9). Every length is bounds-checked so a malformed block is
// rejected as errInvalidBlock rather than panicking or over-allocating.
func parseBlock(vm *ChainVM, data []byte) (*Block, error) {
msg, err := zap.Parse(data)
if err != nil {
return nil, errInvalidBlock
}
if msg.Size() != len(data) {
return nil, errInvalidBlock
}
o := msg.Root()
b := &Block{
vm: vm,
status: StatusUnknown,
}
b.height = o.Uint64(blkHeight)
b.timestamp = time.Unix(0, o.Int64(blkTime))
copy(b.parentID[:], o.BytesFixedSlice(blkParent, 32))
// txs in wire order — the DEX fairness/determinism invariant.
lens := readU32List(o, blkTxLens)
blob := o.Bytes(blkTxBlob)
pos := 0
for _, l := range lens {
if pos+int(l) > len(blob) {
return nil, errInvalidBlock
}
tx := make([]byte, l)
copy(tx, blob[pos:pos+int(l)])
b.txs = append(b.txs, tx)
pos += int(l)
}
// Carried-fills section: the proposer's confirmed fills every validator settles
// from. Must be exactly consumed within its field (no trailing garbage).
fills := o.Bytes(blkFills)
entries, sig, consumed, err := decodeCarriedFills(fills)
if err != nil || consumed != len(fills) {
return nil, errInvalidBlock
}
b.carriedFills = entries
b.fillSig = sig
// Compute block ID from bytes using sha256.
hash := sha256.Sum256(data)
copy(b.id[:], hash[:])
return b, nil
}
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
}
-406
View File
@@ -1,406 +0,0 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package block implements block structure for the DEX VM.
package block
import (
"context"
"errors"
"fmt"
"time"
"github.com/luxfi/chains/dexvm/txs"
"github.com/luxfi/ids"
"github.com/luxfi/zap"
)
var (
ErrBlockTooLarge = errors.New("block exceeds maximum size")
ErrTooManyTxs = errors.New("block contains too many transactions")
ErrInvalidBlockTime = errors.New("invalid block timestamp")
ErrInvalidParent = errors.New("invalid parent block")
ErrBlockNotVerified = errors.New("block not verified")
)
// Status represents the verification status of a block.
type Status uint8
const (
StatusPending Status = iota
StatusProcessing
StatusAccepted
StatusRejected
)
func (s Status) String() string {
switch s {
case StatusPending:
return "pending"
case StatusProcessing:
return "processing"
case StatusAccepted:
return "accepted"
case StatusRejected:
return "rejected"
default:
return "unknown"
}
}
// Block represents a block in the DEX VM.
type Block struct {
// Header fields
id ids.ID
parentID ids.ID
height uint64
timestamp int64
// Block content
transactions []txs.Tx
// Merkle roots
txRoot ids.ID
stateRoot ids.ID
// Producer info
producer ids.NodeID
signature []byte
// Verification status
status Status
verified bool
// Serialized bytes
bytes []byte
}
// NewBlock creates a new block.
func NewBlock(
parentID ids.ID,
height uint64,
timestamp int64,
transactions []txs.Tx,
producer ids.NodeID,
) *Block {
return &Block{
parentID: parentID,
height: height,
timestamp: timestamp,
transactions: transactions,
producer: producer,
status: StatusPending,
}
}
// ID returns the block's unique identifier.
func (b *Block) ID() ids.ID {
if b.id == ids.Empty {
b.id = b.computeID()
}
return b.id
}
// Parent returns the parent block ID.
func (b *Block) Parent() ids.ID {
return b.parentID
}
// Height returns the block height.
func (b *Block) Height() uint64 {
return b.height
}
// Timestamp returns the block timestamp.
func (b *Block) Timestamp() time.Time {
return time.Unix(0, b.timestamp)
}
// TimestampNano returns the block timestamp in nanoseconds.
func (b *Block) TimestampNano() int64 {
return b.timestamp
}
// Transactions returns the transactions in the block.
func (b *Block) Transactions() []txs.Tx {
return b.transactions
}
// TxCount returns the number of transactions in the block.
func (b *Block) TxCount() int {
return len(b.transactions)
}
// TxRoot returns the merkle root of transactions.
func (b *Block) TxRoot() ids.ID {
return b.txRoot
}
// StateRoot returns the state root after applying this block.
func (b *Block) StateRoot() ids.ID {
return b.stateRoot
}
// Producer returns the node that produced this block.
func (b *Block) Producer() ids.NodeID {
return b.producer
}
// Status returns the verification status.
func (b *Block) Status() Status {
return b.status
}
// SetStatus sets the verification status.
func (b *Block) SetStatus(status Status) {
b.status = status
}
// Bytes returns the serialized block.
func (b *Block) Bytes() []byte {
if b.bytes == nil {
b.bytes = b.serialize()
}
return b.bytes
}
// Verify verifies the block's validity.
func (b *Block) Verify(ctx context.Context) error {
// Verify timestamp is not in the future
now := time.Now().UnixNano()
if b.timestamp > now+int64(time.Second) { // Allow 1 second drift
return ErrInvalidBlockTime
}
// Verify each transaction
for _, tx := range b.transactions {
if err := tx.Verify(); err != nil {
return fmt.Errorf("invalid transaction %s: %w", tx.ID(), err)
}
}
b.verified = true
return nil
}
// Accept marks the block as accepted.
func (b *Block) Accept(ctx context.Context) error {
if !b.verified {
return ErrBlockNotVerified
}
b.status = StatusAccepted
return nil
}
// Reject marks the block as rejected.
func (b *Block) Reject(ctx context.Context) error {
b.status = StatusRejected
return nil
}
// computeID computes the block ID from its contents.
func (b *Block) computeID() ids.ID {
// In production, use proper hashing
data := b.serialize()
id, _ := ids.ToID(data)
return id
}
// Block wire (native ZAP, object offsets). timestamp is nanoseconds; the
// transaction list preserves proposer order.
//
// ParentID 32B @ 0
// Height u64 @ 32
// Timestamp i64 @ 40 (nanoseconds)
// TxRoot 32B @ 48
// StateRoot 32B @ 80
// Producer 20B @ 112
// Signature bytes @ 132
// TxLens list @ 140 (u32 per tx; order preserved)
// TxBlob bytes @ 148 (concatenated tx wire bytes)
const (
sbParent = 0
sbHeight = 32
sbTime = 40
sbTxRoot = 48
sbState = 80
sbProd = 112
sbSig = 132
sbTxLens = 140
sbTxBlob = 148
sbSize = 156
)
// serialize serializes the block to its canonical ZAP wire.
func (b *Block) serialize() []byte {
txLens := make([]uint32, len(b.transactions))
var txBlob []byte
for i, tx := range b.transactions {
tb := tx.Bytes()
txLens[i] = uint32(len(tb))
txBlob = append(txBlob, tb...)
}
bld := zap.NewBuilder(zap.HeaderSize + sbSize + len(b.signature) + len(txBlob) + 4*len(txLens) + 128)
txLensOff := writeU32List(bld, txLens)
ob := bld.StartObject(sbSize)
ob.SetBytesFixed(sbParent, b.parentID[:])
ob.SetUint64(sbHeight, b.height)
ob.SetInt64(sbTime, b.timestamp)
ob.SetBytesFixed(sbTxRoot, b.txRoot[:])
ob.SetBytesFixed(sbState, b.stateRoot[:])
ob.SetBytesFixed(sbProd, b.producer[:])
ob.SetBytes(sbSig, b.signature)
ob.SetList(sbTxLens, txLensOff, len(txLens))
ob.SetBytes(sbTxBlob, txBlob)
ob.FinishAsRoot()
return bld.Finish()
}
// BlockParser parses blocks from bytes.
type BlockParser struct {
txParser *txs.TxParser
}
// NewBlockParser creates a new block parser.
func NewBlockParser() *BlockParser {
return &BlockParser{
txParser: &txs.TxParser{},
}
}
// Parse parses a block from its ZAP wire (the inverse of serialize). Every
// length is bounds-checked; a malformed block is rejected rather than panicking.
func (p *BlockParser) Parse(data []byte) (*Block, error) {
msg, err := zap.Parse(data)
if err != nil {
return nil, err
}
if msg.Size() != len(data) {
return nil, errors.New("invalid block: trailing bytes")
}
o := msg.Root()
b := &Block{bytes: data}
copy(b.parentID[:], o.BytesFixedSlice(sbParent, 32))
b.height = o.Uint64(sbHeight)
b.timestamp = o.Int64(sbTime)
copy(b.txRoot[:], o.BytesFixedSlice(sbTxRoot, 32))
copy(b.stateRoot[:], o.BytesFixedSlice(sbState, 32))
copy(b.producer[:], o.BytesFixedSlice(sbProd, 20))
b.signature = appendBytes(o.Bytes(sbSig))
lens := readU32List(o, sbTxLens)
blob := o.Bytes(sbTxBlob)
b.transactions = make([]txs.Tx, 0, len(lens))
pos := 0
for i, l := range lens {
if pos+int(l) > len(blob) {
return nil, fmt.Errorf("invalid block: tx %d truncated", i)
}
tx, err := p.txParser.Parse(blob[pos : pos+int(l)])
if err != nil {
return nil, fmt.Errorf("failed to parse tx %d: %w", i, err)
}
b.transactions = append(b.transactions, tx)
pos += int(l)
}
b.id = b.computeID()
return b, nil
}
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...)
}
// Builder builds new blocks.
type Builder struct {
parentID ids.ID
height uint64
maxBlockSize uint64
maxTxsPerBlock uint32
transactions []txs.Tx
currentSize uint64
}
// NewBuilder creates a new block builder.
func NewBuilder(parentID ids.ID, height uint64, maxBlockSize uint64, maxTxsPerBlock uint32) *Builder {
return &Builder{
parentID: parentID,
height: height,
maxBlockSize: maxBlockSize,
maxTxsPerBlock: maxTxsPerBlock,
transactions: make([]txs.Tx, 0, maxTxsPerBlock),
currentSize: 136, // Base header size
}
}
// AddTx adds a transaction to the pending block.
func (b *Builder) AddTx(tx txs.Tx) error {
txSize := uint64(len(tx.Bytes()) + 4) // tx bytes + length prefix
if b.currentSize+txSize > b.maxBlockSize {
return ErrBlockTooLarge
}
if uint32(len(b.transactions)) >= b.maxTxsPerBlock {
return ErrTooManyTxs
}
b.transactions = append(b.transactions, tx)
b.currentSize += txSize
return nil
}
// Build builds the block.
func (b *Builder) Build(producer ids.NodeID) *Block {
return NewBlock(
b.parentID,
b.height,
time.Now().UnixNano(),
b.transactions,
producer,
)
}
// TxCount returns the number of pending transactions.
func (b *Builder) TxCount() int {
return len(b.transactions)
}
// CurrentSize returns the current block size.
func (b *Builder) CurrentSize() uint64 {
return b.currentSize
}
// Clear clears the builder for reuse.
func (b *Builder) Clear(parentID ids.ID, height uint64) {
b.parentID = parentID
b.height = height
b.transactions = b.transactions[:0]
b.currentSize = 136
}
-58
View File
@@ -1,58 +0,0 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package block
import (
"testing"
"time"
"github.com/luxfi/ids"
"github.com/stretchr/testify/require"
"github.com/luxfi/chains/dexvm/txs"
)
// TestBlockWireRoundTrip proves the native-ZAP block envelope round-trips every
// header field, preserves the nanosecond timestamp (no seconds floor), and keeps
// transactions in proposer order — the DEX determinism invariant.
func TestBlockWireRoundTrip(t *testing.T) {
require := require.New(t)
from := ids.GenerateTestShortID()
var poolID [32]byte
copy(poolID[:], []byte("pool-1"))
tx1 := txs.NewPlaceOrderTx(from, 1, poolID, 0, 100, 5)
tx2 := txs.NewCancelOrderTx(from, 2, poolID, 7)
orig := &Block{
parentID: ids.GenerateTestID(),
height: 11,
timestamp: time.Unix(1_700_000_000, 123_456_789).UnixNano(), // sub-second component
transactions: []txs.Tx{tx1, tx2},
txRoot: ids.GenerateTestID(),
stateRoot: ids.GenerateTestID(),
producer: ids.GenerateTestNodeID(),
signature: []byte("producer-signature"),
}
wire := orig.serialize()
parsed, err := NewBlockParser().Parse(wire)
require.NoError(err)
require.Equal(orig.parentID, parsed.parentID)
require.Equal(orig.height, parsed.height)
require.Equal(orig.timestamp, parsed.timestamp, "nanosecond timestamp preserved (no floor)")
require.Equal(orig.txRoot, parsed.txRoot)
require.Equal(orig.stateRoot, parsed.stateRoot)
require.Equal(orig.producer, parsed.producer)
require.Equal(orig.signature, parsed.signature)
require.Len(parsed.transactions, 2)
require.Equal(tx1.Bytes(), parsed.transactions[0].Bytes(), "tx order preserved (DEX fairness)")
require.Equal(tx2.Bytes(), parsed.transactions[1].Bytes())
// canonical: trailing bytes rejected
_, err = NewBlockParser().Parse(append(append([]byte(nil), wire...), 0x00))
require.Error(err)
}
-202
View File
@@ -1,202 +0,0 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//go:build redteam
// byzantine_settlement_test.go proves the SETTLEMENT layer's byzantine guarantees
// that the fill-attestation tests do NOT cover: the CONSERVATION BOUND is
// independent of TRUST. Attestation (redteam_fill_attestation_test.go) proves a
// carried fill came from the venue key; this file proves that even a fill the venue
// GENUINELY SIGNED cannot make the proxy mint or misvalue.
//
// THREAT MODEL. Here the venue itself is byzantine and CO-LOCATED with the proposer
// (it holds the signing seed), so every fabricated fill it emits carries a VALID
// Ed25519 attestation — the attestation gate passes. The only thing standing
// between a lying venue and minted value is settleFromFills' conservation math:
//
// spent > locked => "would mint" => settle refused, escrow intact
// mixed-side fill stream => over-credit => refused
// (and: the settle is a PURE function of the fill SET, order-independent)
//
// These are the RED C4 / mixed-side / RED #9 guards, exercised through the real
// ChainVM Build->Verify->Accept lifecycle with a genuinely-signing byzantine venue.
package dexvm
import (
"context"
"testing"
"time"
"github.com/luxfi/crypto/ed25519"
"github.com/luxfi/ids"
)
// byzantineVenueKeys returns a deterministic Ed25519 seed + derived public key for
// a byzantine venue that is co-located with the proposer (so it signs whatever
// fills it fabricates, and the attestation always VERIFIES).
func byzantineVenueKeys(t *testing.T) (seed []byte, pub ed25519.PublicKey) {
t.Helper()
seed = make([]byte, ed25519.SeedSize)
for i := range seed {
seed[i] = byte(0x40 + i)
}
priv, err := ed25519.NewKeyFromSeed(seed)
if err != nil {
t.Fatalf("derive venue key: %v", err)
}
return seed, priv.Public().(ed25519.PublicKey)
}
// settleByzantineFills drives one import(locks `lockedQuote` quote) + settling BUY
// relay through the ChainVM with the byzantine venue's `fills`, and returns the
// export legs the C-Chain can claim. Enforcement is ON with the venue key, so the
// (byzantine) fills are VALIDLY ATTESTED — only the conservation math can stop them.
func settleByzantineFills(t *testing.T, fills []Fill, lockedQuote uint64) (taker ids.ShortID, baseAsset, quoteAsset ids.ID, legs map[ids.ID]uint64) {
t.Helper()
ctx := context.Background()
seed, pub := byzantineVenueKeys(t)
cvm, _, cChainSM, proxyChain, _ := newCountingHarness(t, fills)
cvm.inner.Config.FillAttestationSeed = seed // co-located byzantine venue signs
cvm.inner.Config.FillAttestationPubKey = pub // enforcement ON, key matches => VALID attestation
taker = ids.GenerateTestShortID()
quoteAsset = ids.GenerateTestID()
baseAsset = ids.GenerateTestID()
srcUTXOID := seedExportedUTXO(t, cChainSM, proxyChain, taker, quoteAsset, lockedQuote)
importTx := newImportTxBytes(t, taker, cvm.inner.cChainID(), srcUTXOID, quoteAsset, lockedQuote)
relayTx := newSettlingRelayTxBytes(t, taker, srcUTXOID, baseAsset, clobSubmitPayload(quoteAsset, lockedQuote), 0, false)
cvm.inner.clock.Set(time.Unix(1, 0))
cvm.pendingTxs = [][]byte{importTx, relayTx}
built, err := cvm.BuildBlock(ctx)
if err != nil {
t.Fatalf("BuildBlock: %v", err)
}
if err := cvm.SetPreference(ctx, built.ID()); err != nil {
t.Fatalf("SetPreference: %v", err)
}
if err := built.Verify(ctx); err != nil {
t.Fatalf("Verify: %v", err)
}
if err := built.Accept(ctx); err != nil {
t.Fatalf("Accept: %v", err)
}
return taker, baseAsset, quoteAsset, exportedLegsByAsset(t, cChainSM, proxyChain, taker)
}
// TestByzantineVenue_OverNotionalAttestedFillCannotMint is the keystone: a byzantine
// venue signs a fill whose notional EXCEEDS the taker's locked collateral (a BUY of
// 600 base @ price 2 = 1200 quote spent against only 1000 locked). The attestation
// is VALID (the venue holds the seed), so the trust gate passes — yet settleFromFills
// refuses "spent > locked" and NO value moves: no proceeds, no over-refund, escrow
// intact. Conservation is enforced INDEPENDENTLY of who signed the fill.
func TestByzantineVenue_OverNotionalAttestedFillCannotMint(t *testing.T) {
const lockedQuote uint64 = 1000
// BUY: receives base = 600, spends quote = 600*2 = 1200 > 1000 locked (mint attempt).
fills := []Fill{{Price: fp(2), Size: 600, Side: 0}}
_, baseAsset, quoteAsset, legs := settleByzantineFills(t, fills, lockedQuote)
if legs[baseAsset] != 0 {
t.Fatalf("MINT: %d base proceeds exported for a fill spending 1200 quote against 1000 locked — "+
"a validly-attested over-notional fill minted value", legs[baseAsset])
}
// No over-refund either: the conservation refusal leaves the escrow intact rather
// than exporting a partial/refund derived from the impossible fill.
var exported uint64
for _, v := range legs {
exported += v
}
if exported > lockedQuote {
t.Fatalf("MINT: exported %d > locked %d (proxy minted against its own escrow)", exported, lockedQuote)
}
if legs[quoteAsset] != 0 {
t.Fatalf("over-notional fill produced a %d quote refund; the settle must refuse entirely (escrow intact), not partially settle", legs[quoteAsset])
}
t.Logf("CONSERVATION BOUND HELD: a VALIDLY-ATTESTED over-notional fill (1200 spent vs 1000 locked) minted 0 — escrow intact")
}
// TestByzantineVenue_WithinCollateralAttestedFillSettles is the positive control:
// the SAME byzantine venue signs a fill WITHIN the locked collateral (400 base @ 2 =
// 800 spent <= 1000 locked). This one settles — proceeds 400 base + refund 200 quote
// — proving the discriminator is the CONSERVATION BOUND (spent<=locked), not the
// attestation (both fills are validly signed). value_in == value_out exactly.
func TestByzantineVenue_WithinCollateralAttestedFillSettles(t *testing.T) {
const lockedQuote uint64 = 1000
// BUY: receives base = 400, spends quote = 400*2 = 800 <= 1000 locked.
fills := []Fill{{Price: fp(2), Size: 400, Side: 0}}
_, baseAsset, quoteAsset, legs := settleByzantineFills(t, fills, lockedQuote)
if legs[baseAsset] != 400 {
t.Fatalf("within-collateral attested fill must settle: base proceeds=%d, want 400", legs[baseAsset])
}
if legs[quoteAsset] != 200 {
t.Fatalf("refund leg wrong: quote refund=%d, want 200 (locked 1000 - spent 800)", legs[quoteAsset])
}
// value_out (proceeds valued at price + refund) conserves the 1000 locked: the
// 400 base proceeds cost 800 quote, + 200 quote refund == 1000 quote-equivalent.
if legs[quoteAsset]+ /*spent*/ 800 != lockedQuote {
t.Fatalf("CONSERVATION: refund %d + spent 800 != locked %d", legs[quoteAsset], lockedQuote)
}
t.Logf("CONTROL: a within-collateral attested fill settled (400 base proceeds + 200 quote refund) — bound is spent<=locked, not trust")
}
// TestByzantineVenue_MixedSideAttestedFillRefused proves the single-side guard under
// a genuinely-signing byzantine venue: a fill stream mixing BUY and SELL (which no
// honest single marketable submit can produce) is refused at settle, so the venue
// cannot over-credit by returning both-side volume. The attestation is valid; the
// settle refuses on the mixed-side invariant, leaving the escrow intact (no mint).
func TestByzantineVenue_MixedSideAttestedFillRefused(t *testing.T) {
const lockedQuote uint64 = 1000
// A lying venue returns [BUY 100, SELL 300]: the mixed-side over-credit attempt.
fills := []Fill{{Price: fp(2), Size: 100, Side: 0}, {Price: fp(2), Size: 300, Side: 1}}
_, baseAsset, quoteAsset, legs := settleByzantineFills(t, fills, lockedQuote)
var exported uint64
for _, v := range legs {
exported += v
}
if legs[baseAsset] != 0 {
t.Fatalf("MIXED-SIDE OVER-CREDIT: %d base proceeds exported for a mixed BUY/SELL stream — the single-side guard failed", legs[baseAsset])
}
if exported > lockedQuote {
t.Fatalf("MINT via mixed-side: exported %d > locked %d", exported, lockedQuote)
}
if legs[quoteAsset] != 0 {
t.Fatalf("mixed-side fill produced a %d quote refund; settle must refuse entirely (escrow intact)", legs[quoteAsset])
}
t.Logf("MIXED-SIDE GUARD HELD: a validly-attested [BUY,SELL] stream minted 0 — escrow intact")
}
// TestByzantineSettlement_DeterministicRegardlessOfFillOrder proves settle is a PURE
// FUNCTION of the fill SET, not its order (threat #5, RED #9). The byzantine venue
// returns the SAME two fills in two different orderings to two independent ChainVMs;
// both settle to byte-identical export legs. A settlement that depended on fill order
// would let a byzantine proposer bias the outcome by reordering; it does not.
func TestByzantineSettlement_DeterministicRegardlessOfFillOrder(t *testing.T) {
const lockedQuote uint64 = 1000
// Two same-side (BUY) fills; total spent = 3*100 + 2*150 = 600 <= 1000 locked.
fillsAB := []Fill{{Price: fp(3), Size: 100, Side: 0}, {Price: fp(2), Size: 150, Side: 0}}
fillsBA := []Fill{{Price: fp(2), Size: 150, Side: 0}, {Price: fp(3), Size: 100, Side: 0}}
_, baseA, quoteA, legsAB := settleByzantineFills(t, fillsAB, lockedQuote)
_, baseB, quoteB, legsBA := settleByzantineFills(t, fillsBA, lockedQuote)
// Proceeds (base) = 100 + 150 = 250; spent quote = 300 + 300 = 600; refund = 400.
if legsAB[baseA] != 250 || legsBA[baseB] != 250 {
t.Fatalf("proceeds differ by fill order: AB=%d BA=%d, want 250 each", legsAB[baseA], legsBA[baseB])
}
if legsAB[quoteA] != legsBA[quoteB] {
t.Fatalf("REORDER-DEPENDENT SETTLEMENT: refund AB=%d != BA=%d — settle is not a pure function of the fill set",
legsAB[quoteA], legsBA[quoteB])
}
if legsAB[quoteA] != 400 {
t.Fatalf("refund=%d, want 400 (locked 1000 - spent 600)", legsAB[quoteA])
}
t.Logf("ORDER-INDEPENDENT: both fill orderings settled to 250 base proceeds + 400 quote refund")
}
-210
View File
@@ -1,210 +0,0 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dexvm
import (
"encoding/binary"
"fmt"
)
// carried_fills.go — the CARRIED-FILLS primitive of the stateless atomic ZAP
// proxy: the block/vertex-borne record of the d-chain matcher's confirmed fills.
//
// ─────────────────────────────────────────────────────────────────────────────
// CONSENSUS-SAFETY RATIONALE (RED finding #9 — per-validator relay forks the
// chain). The proxy's block output MUST be a pure function of (height, carried
// time, tx bytes). A clob_submit relay is STATE-MUTATING on the d-chain AND its
// returned fills depend on WHEN the call lands (the book moves between calls). If
// each validator issues its own relay while processing a block, each receives
// independently-timed fills => different settlement export => different StateRoot
// => the network forks. (Proven by TestRED_PerValidatorRelay_SplitsConsensus.)
//
// THE FIX (this primitive). The relay is performed EXACTLY ONCE for the whole
// network — by the block PROPOSER, at BuildBlock/BuildVertex (VM.obtainFills) —
// and the confirmed fills are CARRIED in the block/vertex bytes. Every validator
// (including the proposer at Verify) settles purely from these carried fills
// (VM.settleCarried); no validator ever relays during Verify or Accept. The
// matcher is therefore hit at most once per order, network-wide, and every node
// reproduces byte-identical settlement => identical StateRoot.
//
// ─────────────────────────────────────────────────────────────────────────────
// WIRE FORMAT — NETWORK-UPGRADE-GATED, LOCKSTEP VALIDATOR CHANGE. Carrying fills
// in the block/vertex bytes CHANGES THE BLOCK/VERTEX WIRE FORMAT (block.go
// Block.Bytes/parseBlock and dag_vertex.go serialize/deserializeDexVertex). It is
// consensus-breaking: a node on the old format cannot parse a new block and vice
// versa. It MUST be activated in lockstep across the whole validator set behind a
// network upgrade (the genesis/upgrade gate is the operator's call — see the
// human sign-off note in the package README/commit). The fills section is
// length-prefixed and self-delimiting so the format is unambiguous.
//
// TRUST SURFACE — bounded, interim. Trusting a single proposer to report the
// fills introduces a proposer-trust surface (a lying proposer could carry
// fabricated fills). It is BOUNDED BY VALUE CONSERVATION, not by trust: the
// settle path (settleFromFills) refuses spent > locked (the proxy never mints),
// so the blast radius of a lying proposer is at most ONE taker's own escrow —
// never supply inflation. This is the canonical model TODAY because the venue is
// single-operator. The trustless path (the d-chain SIGNS its fills; a P3Q ->
// starkfri verifier checks the signature on-chain) rides the RESERVED signature
// field below, so making the proxy trustless later needs NO second wire-format
// bump.
//
// FROZEN-FRAME NOTE. This is the BLOCK format, NOT the ZAP fill frame. The 17-byte
// FillWireSize ZAP frame (relay.go / dex/pkg/zapwire / precompile) carries a uint64
// FIXED-POINT ×PriceScale price and a uint64 ATOMIC-BASE-UNIT size; the per-fill
// encoding here reuses the same exact-integer price[8]+size[8]+side[1] layout for
// continuity but is a distinct, block-level structure.
// carriedFill binds one settling relay's confirmed fills to its position in the
// block. txIndex is the relay tx's index in the block (the same coordinate that
// keys the idempotency receipt and seeds the deterministic settlement export), so
// settleCarried can match carried fills back to the planned relay and settle the
// correct collateral with a byte-identical export identity on every validator.
type carriedFill struct {
// txIndex is the relay tx's position in the block.
txIndex uint32
// fills are the d-chain matcher's confirmed fills for that relay (possibly
// empty: a zero-fill submit still carries an entry so the validator settles a
// full refund rather than guessing).
fills []Fill
}
// maxCarriedFillEntries / maxFillsPerEntry bound the carried-fills section so a
// malformed or hostile block cannot force an unbounded allocation at parse time.
// They are generous relative to MaxTxsPerBlock yet finite; a block exceeding them
// is rejected as malformed wire.
const (
maxCarriedFillEntries = 1 << 20
maxFillsPerEntry = 1 << 20
)
// encodeCarriedFills serializes the carried-fills section appended to a block /
// vertex:
//
// entryCount[4]
// for each entry:
// txIndex[4]
// fillCount[4]
// fillCount × ( price[8] | size[8] | side[1] ) // FillWireSize bytes each
// sigLen[4]
// sig[sigLen] // reserved (empty today)
//
// The sig field is the RESERVED d-chain fill-attestation slot (see the trustless
// path note above): carried now so the future signed-fills upgrade needs no
// further wire-format change. Today it is always empty.
func encodeCarriedFills(entries []carriedFill, sig []byte) []byte {
size := 4
for _, e := range entries {
size += 4 + 4 + len(e.fills)*FillWireSize
}
size += 4 + len(sig)
buf := make([]byte, size)
off := 0
binary.BigEndian.PutUint32(buf[off:], uint32(len(entries)))
off += 4
for _, e := range entries {
binary.BigEndian.PutUint32(buf[off:], e.txIndex)
off += 4
binary.BigEndian.PutUint32(buf[off:], uint32(len(e.fills)))
off += 4
for _, f := range e.fills {
binary.BigEndian.PutUint64(buf[off:off+8], f.Price)
binary.BigEndian.PutUint64(buf[off+8:off+16], f.Size)
buf[off+16] = f.Side
off += FillWireSize
}
}
binary.BigEndian.PutUint32(buf[off:], uint32(len(sig)))
off += 4
copy(buf[off:], sig)
return buf
}
// decodeCarriedFills parses the carried-fills section written by
// encodeCarriedFills. It returns the entries, the reserved signature, the number
// of bytes consumed, and any error. Every field is range-checked so a hostile or
// truncated block cannot inject a malformed fill into settlement or over-allocate:
// - the per-fill price/size must be strictly positive and side ∈ {0,1} (the
// SAME boundary invariant DecodeFills enforces on the ZAP wire), so no
// impossible Fill ever reaches settleFromFills;
// - counts are bounded (maxCarriedFillEntries / maxFillsPerEntry);
// - the section must be exactly consumed by the declared lengths.
//
// data is the carried-fills section ONLY (the block/vertex parser passes the
// remaining bytes after the txs). consumed lets a caller assert the section ends
// the block (no trailing garbage).
func decodeCarriedFills(data []byte) (entries []carriedFill, sig []byte, consumed int, err error) {
if len(data) < 4 {
return nil, nil, 0, fmt.Errorf("carried fills: section too short: %d", len(data))
}
off := 0
n := int(binary.BigEndian.Uint32(data[off:]))
off += 4
if n < 0 || n > maxCarriedFillEntries {
return nil, nil, 0, fmt.Errorf("carried fills: entry count out of range: %d", n)
}
entries = make([]carriedFill, 0, n)
for i := 0; i < n; i++ {
if off+8 > len(data) {
return nil, nil, 0, fmt.Errorf("carried fills: truncated entry header %d", i)
}
txIndex := binary.BigEndian.Uint32(data[off:])
off += 4
fc := int(binary.BigEndian.Uint32(data[off:]))
off += 4
if fc < 0 || fc > maxFillsPerEntry {
return nil, nil, 0, fmt.Errorf("carried fills: fill count out of range: %d", fc)
}
if off+fc*FillWireSize > len(data) {
return nil, nil, 0, fmt.Errorf("carried fills: truncated fills for entry %d (count=%d)", i, fc)
}
fills := make([]Fill, 0, fc)
for j := 0; j < fc; j++ {
p := binary.BigEndian.Uint64(data[off : off+8])
s := binary.BigEndian.Uint64(data[off+8 : off+16])
side := data[off+16]
off += FillWireSize
if p == 0 {
return nil, nil, 0, fmt.Errorf("carried fills: entry %d fill %d invalid price %d", i, j, p)
}
if s == 0 {
return nil, nil, 0, fmt.Errorf("carried fills: entry %d fill %d invalid size %d", i, j, s)
}
if side > 1 {
return nil, nil, 0, fmt.Errorf("carried fills: entry %d fill %d invalid side %d", i, j, side)
}
fills = append(fills, Fill{Price: p, Size: s, Side: side})
}
entries = append(entries, carriedFill{txIndex: txIndex, fills: fills})
}
if off+4 > len(data) {
return nil, nil, 0, fmt.Errorf("carried fills: missing signature length")
}
sigLen := int(binary.BigEndian.Uint32(data[off:]))
off += 4
if sigLen < 0 || off+sigLen > len(data) {
return nil, nil, 0, fmt.Errorf("carried fills: signature length out of range: %d", sigLen)
}
if sigLen > 0 {
sig = make([]byte, sigLen)
copy(sig, data[off:off+sigLen])
}
off += sigLen
return entries, sig, off, nil
}
// fillsForTx returns the carried fills bound to txIndex, and whether an entry was
// present. An ABSENT entry (found=false) means the block carried no fills for that
// relay — the settle treats it as a full refund (nothing realized). A PRESENT but
// empty entry (found=true, len==0) is an explicit zero-fill, settled identically;
// the distinction is kept so a proposer can be explicit and a validator never has
// to infer intent.
func fillsForTx(entries []carriedFill, txIndex uint32) ([]Fill, bool) {
for _, e := range entries {
if e.txIndex == txIndex {
return e.fills, true
}
}
return nil, false
}
-220
View File
@@ -1,220 +0,0 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// carried_fills_test.go — executable spec for the carried-fills wire format (the
// RED #9 block/vertex format change). It pins:
// - the codec round-trips byte-exactly (proposer encode -> validator decode);
// - malformed/hostile sections are rejected at the boundary (no panic, no
// impossible Fill into settlement);
// - the BLOCK bytes carry fills through Bytes() -> parseBlock and the block id
// commits to them (tamper detection);
// - the VERTEX bytes carry fills through serialize -> deserialize and the vertex
// id commits to them.
package dexvm
import (
"encoding/binary"
"testing"
"time"
"github.com/luxfi/ids"
"github.com/luxfi/log"
)
// TestCarriedFills_CodecRoundTrip proves encode/decode is an exact inverse for a
// representative entry set (multiple txIndexes, multiple fills, a zero-fill entry).
func TestCarriedFills_CodecRoundTrip(t *testing.T) {
in := []carriedFill{
{txIndex: 1, fills: []Fill{{Price: fp(2), Size: 50, Side: 0}, {Price: fp(2), Size: 50, Side: 0}}},
{txIndex: 3, fills: nil}, // explicit zero-fill (a failed/empty submit)
{txIndex: 7, fills: []Fill{{Price: fp(1.5), Size: 4, Side: 1}}},
}
enc := encodeCarriedFills(in, nil)
out, sig, consumed, err := decodeCarriedFills(enc)
if err != nil {
t.Fatalf("decode: %v", err)
}
if consumed != len(enc) {
t.Fatalf("consumed %d != section len %d", consumed, len(enc))
}
if sig != nil {
t.Fatalf("expected empty reserved signature, got %d bytes", len(sig))
}
if len(out) != len(in) {
t.Fatalf("entry count: got %d want %d", len(out), len(in))
}
for i := range in {
if out[i].txIndex != in[i].txIndex || len(out[i].fills) != len(in[i].fills) {
t.Fatalf("entry %d mismatch: got %+v want %+v", i, out[i], in[i])
}
for j := range in[i].fills {
if out[i].fills[j] != in[i].fills[j] {
t.Fatalf("entry %d fill %d: got %+v want %+v", i, j, out[i].fills[j], in[i].fills[j])
}
}
}
// fillsForTx resolves entries; an absent txIndex returns (nil,false).
if f, ok := fillsForTx(out, 1); !ok || len(f) != 2 {
t.Fatalf("fillsForTx(1): ok=%v len=%d, want ok=true len=2", ok, len(f))
}
if f, ok := fillsForTx(out, 3); !ok || len(f) != 0 {
t.Fatalf("fillsForTx(3) explicit zero-fill: ok=%v len=%d, want ok=true len=0", ok, len(f))
}
if _, ok := fillsForTx(out, 99); ok {
t.Fatalf("fillsForTx(99): want absent, got present")
}
}
// TestCarriedFills_ReservedSignatureRoundTrips proves the reserved fill-attestation
// field survives the codec (the trustless-path upgrade carries data here without a
// further wire-format change).
func TestCarriedFills_ReservedSignatureRoundTrips(t *testing.T) {
sigIn := []byte("reserved-d-chain-fill-attestation-bytes")
enc := encodeCarriedFills([]carriedFill{{txIndex: 0, fills: []Fill{{Price: fp(1), Size: 1, Side: 0}}}}, sigIn)
_, sigOut, consumed, err := decodeCarriedFills(enc)
if err != nil {
t.Fatalf("decode: %v", err)
}
if consumed != len(enc) {
t.Fatalf("consumed %d != len %d", consumed, len(enc))
}
if string(sigOut) != string(sigIn) {
t.Fatalf("reserved signature round-trip: got %q want %q", sigOut, sigIn)
}
}
// TestCarriedFills_RejectsMalformed proves the decoder rejects hostile/truncated
// sections at the boundary (the SAME positivity/side invariant DecodeFills enforces
// on the ZAP wire) — so no impossible Fill ever reaches settlement and no
// over-allocation is possible.
func TestCarriedFills_RejectsMalformed(t *testing.T) {
good := encodeCarriedFills([]carriedFill{{txIndex: 0, fills: []Fill{{Price: fp(1), Size: 1, Side: 0}}}}, nil)
t.Run("truncated_header", func(t *testing.T) {
if _, _, _, err := decodeCarriedFills([]byte{0, 0}); err == nil {
t.Fatal("accepted a 2-byte section (need >=4 for the entry count)")
}
})
t.Run("invalid_side", func(t *testing.T) {
bad := make([]byte, len(good))
copy(bad, good)
// side byte = entryCount[4] + txIndex[4] + fillCount[4] + price[8] + size[8].
bad[4+4+4+8+8] = 7
if _, _, _, err := decodeCarriedFills(bad); err == nil {
t.Fatal("accepted a fill with side byte 7 (must be 0 or 1)")
}
})
t.Run("nonpositive_size", func(t *testing.T) {
bad := make([]byte, len(good))
copy(bad, good)
binary.BigEndian.PutUint64(bad[4+4+4+8:4+4+4+16], 0) // size = 0.0
if _, _, _, err := decodeCarriedFills(bad); err == nil {
t.Fatal("accepted a fill with size 0 (must be finite and strictly positive)")
}
})
t.Run("overlarge_entry_count", func(t *testing.T) {
bad := make([]byte, 4)
binary.BigEndian.PutUint32(bad, maxCarriedFillEntries+1)
if _, _, _, err := decodeCarriedFills(bad); err == nil {
t.Fatal("accepted an entry count above the bound (over-allocation guard)")
}
})
t.Run("missing_signature_length", func(t *testing.T) {
// A valid entry section but cut off before the sigLen field.
bad := good[:len(good)-4]
if _, _, _, err := decodeCarriedFills(bad); err == nil {
t.Fatal("accepted a section missing the signature length")
}
})
}
// TestBlock_CarriedFillsRoundTripAndIDBinding proves a BLOCK serializes its carried
// fills and that parseBlock recovers them, and that the block id (hash of bytes)
// commits to the fills — a peer cannot swap the proposer's fills while keeping the
// same id.
func TestBlock_CarriedFillsRoundTripAndIDBinding(t *testing.T) {
cvm := NewChainVM(log.NewNoOpLogger())
var parent ids.ID
b := &Block{
vm: cvm,
parentID: parent,
height: 5,
timestamp: time.Unix(1_700_000_000, 0),
txs: [][]byte{[]byte("tx-a"), []byte("tx-bb")},
carriedFills: []carriedFill{
{txIndex: 1, fills: []Fill{{Price: fp(2), Size: 50, Side: 0}}},
},
}
wire := b.Bytes()
parsed, err := parseBlock(cvm, wire)
if err != nil {
t.Fatalf("parseBlock: %v", err)
}
if parsed.height != b.height || !parsed.timestamp.Equal(b.timestamp) || len(parsed.txs) != len(b.txs) {
t.Fatalf("header/txs round-trip mismatch: got h=%d ts=%v ntx=%d", parsed.height, parsed.timestamp, len(parsed.txs))
}
if len(parsed.carriedFills) != 1 || parsed.carriedFills[0].txIndex != 1 ||
len(parsed.carriedFills[0].fills) != 1 || parsed.carriedFills[0].fills[0] != b.carriedFills[0].fills[0] {
t.Fatalf("carried fills not preserved through block round-trip: %+v", parsed.carriedFills)
}
// ID binding: change ONE carried fill and the block id (hash of bytes) must
// differ — the fills are committed, not malleable.
b2 := *b
b2.carriedFills = []carriedFill{{txIndex: 1, fills: []Fill{{Price: fp(2), Size: 999, Side: 0}}}}
p1, _ := parseBlock(cvm, b.Bytes())
p2, _ := parseBlock(cvm, b2.Bytes())
if p1.id == p2.id {
t.Fatalf("block id does NOT commit to carried fills: different fills produced the same id %x", p1.id[:8])
}
}
// TestVertex_CarriedFillsRoundTripAndIDBinding proves a VERTEX serializes its
// carried fills, deserialize recovers them, and the vertex id commits to them.
func TestVertex_CarriedFillsRoundTripAndIDBinding(t *testing.T) {
cvm := NewChainVM(log.NewNoOpLogger())
v := &DexVertex{
height: 3,
epoch: 0,
timestamp: time.Unix(1_650_000_000, 7),
parents: []ids.ID{{0x01}},
rawTxs: [][]byte{[]byte("raw-tx-1")},
carriedFills: []carriedFill{
{txIndex: 0, fills: []Fill{{Price: fp(3), Size: 9, Side: 1}}},
},
vm: cvm,
}
v.id = v.computeID()
wire := serializeDexVertex(v)
parsed, err := deserializeDexVertex(wire, cvm)
if err != nil {
t.Fatalf("deserializeDexVertex: %v", err)
}
if len(parsed.carriedFills) != 1 || parsed.carriedFills[0].fills[0] != v.carriedFills[0].fills[0] {
t.Fatalf("carried fills not preserved through vertex round-trip: %+v", parsed.carriedFills)
}
// The recomputed id (deserialize calls computeID) must match the original — the
// fills are part of the committed id.
if parsed.id != v.id {
t.Fatalf("vertex id mismatch after round-trip: got %x want %x", parsed.id[:8], v.id[:8])
}
// Tamper: a vertex identical except for one carried fill must have a different id.
tampered := &DexVertex{
height: v.height,
epoch: v.epoch,
timestamp: v.timestamp,
parents: v.parents,
rawTxs: v.rawTxs,
carriedFills: []carriedFill{{txIndex: 0, fills: []Fill{{Price: fp(3), Size: 9999, Side: 1}}}},
}
if tampered.computeID() == v.id {
t.Fatalf("vertex id does NOT commit to carried fills: different fills produced the same id %x", v.id[:8])
}
}
-419
View File
@@ -1,419 +0,0 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dexvm
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"net/http"
"sync"
"time"
"github.com/luxfi/consensus/engine/dag/vertex"
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/node/vms/types/fee"
"github.com/luxfi/vm"
"github.com/luxfi/vm/chain"
)
var (
_ chain.ChainVM = (*ChainVM)(nil)
_ vertex.DAGVM = (*ChainVM)(nil)
)
var (
errInvalidBlock = errors.New("invalid block")
errBlockNotFound = errors.New("block not found")
errNoBlocksBuilt = errors.New("no blocks to build")
errVMNotInitialized = errors.New("VM not initialized")
// Genesis block ID (all zeros)
genesisBlockID = ids.ID{}
)
// ChainVM wraps the functional DEX VM to implement the chain.ChainVM interface
// required for running as an L2 chain plugin.
type ChainVM struct {
// The inner functional VM
inner *VM
// Logger
log log.Logger
// Lock for thread safety
lock sync.RWMutex
// Block storage
blocks map[ids.ID]*Block
// Last accepted block info
lastAcceptedID ids.ID
lastAcceptedHeight uint64
// Preferred block (tip of the chain we're building on)
preferredID ids.ID
// Pending transactions for next block
pendingTxs [][]byte
// Channel to notify consensus of new blocks
toEngine chan<- vm.Message
// Initialization state
initialized bool
// Fee policy gating user-submitted tx admission: the canonical FlatPolicy
// at MinTxFeeFloor denominated in canonical LUX (network-independent, so an
// L2/L3 still settles DEX fees to LUX). Internal (consensus engine -> VM)
// paths bypass this gate; only SubmitTx consults it. See feegate.go.
feePolicy fee.Policy
// networkID is this chain's primary-network identity (1 mainnet, 2 testnet,
// ...), recorded at Init. It no longer selects the fee asset — fees are LUX
// on every network — but is retained for diagnostics/identity.
networkID uint32
}
// NewChainVM creates a new ChainVM that wraps a functional DEX VM. The block
// cadence is NOT set here: it is driven by the consensus engine and bounded only
// by config.Config.BlockInterval (default 1ms, HFT), the single source of truth.
// dexvm imposes NO min-block-time / whole-second floor of its own — block times
// carry sub-second (UnixNano) on the wire and the proposer clamps them merely
// non-decreasing (see BuildBlock / BuildVertex).
func NewChainVM(logger log.Logger) *ChainVM {
return &ChainVM{
inner: &VM{},
log: logger,
blocks: make(map[ids.ID]*Block),
}
}
// Initialize implements the VM interface
func (cvm *ChainVM) Initialize(
ctx context.Context,
vmInit vm.Init,
) error {
cvm.lock.Lock()
defer cvm.lock.Unlock()
// Store the message channel
cvm.toEngine = vmInit.ToEngine
// Attach the canonical D-Chain fee policy. D-Chain is user-tx-accepting
// so it charges a non-zero floor at MinTxFeeFloor; boot-time Validate
// (fee.Validate) refuses zero-fee user-facing chains before they ever
// accept a block. The policy denominates the fee in canonical LUX and is
// therefore network-INDEPENDENT — an L2/L3 with its own native currency
// still settles DEX fees to LUX. networkID is retained as chain identity.
if vmInit.Runtime != nil {
cvm.networkID = vmInit.Runtime.NetworkID
}
cvm.feePolicy = newFeePolicy()
if err := fee.Validate(cvm.feePolicy); err != nil {
return fmt.Errorf("dexvm: fee policy: %w", err)
}
// Initialize the inner VM
if err := cvm.inner.Initialize(
ctx,
vmInit,
); err != nil {
return err
}
// Set logger for inner VM
cvm.inner.log = cvm.log
// Create genesis block
genesisBlock := &Block{
vm: cvm,
id: genesisBlockID,
parentID: ids.Empty,
height: 0,
timestamp: time.Unix(0, 0),
txs: nil,
status: StatusAccepted,
}
cvm.blocks[genesisBlockID] = genesisBlock
cvm.lastAcceptedID = genesisBlockID
cvm.lastAcceptedHeight = 0
cvm.preferredID = genesisBlockID
cvm.initialized = true
if !cvm.log.IsZero() {
cvm.log.Info("DEX ChainVM initialized",
"genesisID", genesisBlockID,
)
}
return nil
}
// SetState implements the VM interface
func (cvm *ChainVM) SetState(ctx context.Context, state uint32) error {
return cvm.inner.SetState(ctx, state)
}
// Shutdown implements the VM interface
func (cvm *ChainVM) Shutdown(ctx context.Context) error {
return cvm.inner.Shutdown(ctx)
}
// Version implements the VM interface
func (cvm *ChainVM) Version(ctx context.Context) (string, error) {
return cvm.inner.Version(ctx)
}
// NewHTTPHandler implements the chain.ChainVM interface
func (cvm *ChainVM) NewHTTPHandler(ctx context.Context) (http.Handler, error) {
handlers, err := cvm.inner.CreateHandlers(ctx)
if err != nil {
return nil, err
}
mux := http.NewServeMux()
for path, handler := range handlers {
if path == "" {
path = "/"
}
mux.Handle(path, handler)
}
return mux, nil
}
// CreateHandlers implements the interface expected by chain manager for HTTP registration
func (cvm *ChainVM) CreateHandlers(ctx context.Context) (map[string]http.Handler, error) {
return cvm.inner.CreateHandlers(ctx)
}
// HealthCheck implements the VM interface
func (cvm *ChainVM) HealthCheck(ctx context.Context) (chain.HealthResult, error) {
return cvm.inner.HealthCheck(ctx)
}
// Connected implements the chain.ChainVM interface
func (cvm *ChainVM) Connected(ctx context.Context, nodeID ids.NodeID, v *chain.VersionInfo) error {
if v == nil {
return nil
}
// chain.VersionInfo is an alias for version.Application, so we can pass it directly
return cvm.inner.Connected(ctx, nodeID, v)
}
// Disconnected implements the VM interface
func (cvm *ChainVM) Disconnected(ctx context.Context, nodeID ids.NodeID) error {
return cvm.inner.Disconnected(ctx, nodeID)
}
// Gossip implements the VM interface
func (cvm *ChainVM) Gossip(ctx context.Context, nodeID ids.NodeID, msg []byte) error {
return cvm.inner.Gossip(ctx, nodeID, msg)
}
// Request implements the VM interface
func (cvm *ChainVM) Request(ctx context.Context, nodeID ids.NodeID, requestID uint32, deadline time.Time, request []byte) error {
return cvm.inner.Request(ctx, nodeID, requestID, deadline, request)
}
// Response implements the VM interface
func (cvm *ChainVM) Response(ctx context.Context, nodeID ids.NodeID, requestID uint32, response []byte) error {
return cvm.inner.Response(ctx, nodeID, requestID, response)
}
// BuildBlock implements the chain.ChainVM interface. It builds a new block from
// pending transactions AND, as the block PROPOSER, performs the network-wide-ONCE
// d-chain relay (VM.BuildBlockResult -> obtainFills), carrying the confirmed fills
// in the block bytes so every validator settles from them without relaying (RED
// finding #9). This is the ONLY chain entry point that triggers a d-chain relay;
// Verify and Accept never do.
//
// The proposer chooses the block time here (wall clock, clamped non-decreasing)
// and carries it in the bytes — the consensus-agreement point. The block id is the
// hash of the serialized bytes (header + txs + carried fills), so the id commits to
// the carried fills (a peer cannot swap fills while keeping the same id).
func (cvm *ChainVM) BuildBlock(ctx context.Context) (chain.Block, error) {
cvm.lock.Lock()
defer cvm.lock.Unlock()
if !cvm.initialized {
return nil, errVMNotInitialized
}
parent, ok := cvm.blocks[cvm.preferredID]
if !ok {
return nil, fmt.Errorf("preferred block not found: %s", cvm.preferredID)
}
newHeight := parent.height + 1
// Proposer-chosen block time, clamped non-decreasing vs the last processed
// block so time never goes backwards on a re-proposal / clock skew. The SAME
// value is carried in the bytes and fed to ProcessBlock by every validator.
newTimestamp := cvm.inner.clock.Time()
if last := cvm.inner.GetLastBlockTime(); newTimestamp.Before(last) {
newTimestamp = last
}
txs := cvm.pendingTxs
// Proposer build: plan + relay-once + obtain the carried fills. obtainFills is
// the single d-chain relay for this block, network-wide.
result, err := cvm.inner.BuildBlockResult(ctx, newHeight, newTimestamp, txs)
if err != nil {
return nil, fmt.Errorf("dexvm: build block result: %w", err)
}
block := &Block{
vm: cvm,
parentID: cvm.preferredID,
height: newHeight,
timestamp: newTimestamp,
txs: txs,
carriedFills: result.carriedFills,
fillSig: result.fillSig,
result: result,
status: StatusProcessing,
}
// Block id = hash of the serialized bytes (commits to the carried fills).
hash := sha256.Sum256(block.Bytes())
copy(block.id[:], hash[:])
cvm.pendingTxs = nil
cvm.blocks[block.id] = block
if !cvm.log.IsZero() {
cvm.log.Debug("Built block",
"id", block.id,
"height", newHeight,
"txCount", len(block.txs),
"carriedFillEntries", len(block.carriedFills),
)
}
return block, nil
}
// ParseBlock implements the chain.ChainVM interface.
// It parses a block from bytes.
func (cvm *ChainVM) ParseBlock(ctx context.Context, data []byte) (chain.Block, error) {
cvm.lock.Lock()
defer cvm.lock.Unlock()
block, err := parseBlock(cvm, data)
if err != nil {
return nil, err
}
// Check if we already have this block
if existingBlock, ok := cvm.blocks[block.id]; ok {
return existingBlock, nil
}
// Store the new block
cvm.blocks[block.id] = block
return block, nil
}
// GetBlock implements the chain.ChainVM interface.
// It returns a block by its ID.
func (cvm *ChainVM) GetBlock(ctx context.Context, blkID ids.ID) (chain.Block, error) {
cvm.lock.RLock()
defer cvm.lock.RUnlock()
block, ok := cvm.blocks[blkID]
if !ok {
return nil, errBlockNotFound
}
return block, nil
}
// SetPreference implements the chain.ChainVM interface.
// It sets the preferred block for building new blocks.
func (cvm *ChainVM) SetPreference(ctx context.Context, blkID ids.ID) error {
cvm.lock.Lock()
defer cvm.lock.Unlock()
if _, ok := cvm.blocks[blkID]; !ok {
return fmt.Errorf("block not found: %s", blkID)
}
cvm.preferredID = blkID
if !cvm.log.IsZero() {
cvm.log.Debug("Set preference", "blockID", blkID)
}
return nil
}
// LastAccepted implements the chain.ChainVM interface.
// It returns the ID of the last accepted chain.
func (cvm *ChainVM) LastAccepted(ctx context.Context) (ids.ID, error) {
cvm.lock.RLock()
defer cvm.lock.RUnlock()
return cvm.lastAcceptedID, nil
}
// GetBlockIDAtHeight returns the block ID at the given height
func (cvm *ChainVM) GetBlockIDAtHeight(ctx context.Context, height uint64) (ids.ID, error) {
cvm.lock.RLock()
defer cvm.lock.RUnlock()
for id, block := range cvm.blocks {
if block.height == height && block.status == StatusAccepted {
return id, nil
}
}
return ids.Empty, errBlockNotFound
}
// SubmitTx adds a transaction to the pending pool. This is the canonical
// user-mempool entry on D-Chain — every public submission funnels through
// here. The FeePolicy gate refuses zero-fee tx before the bytes touch
// pendingTxs.
//
// Internal callers (consensus engine -> VM, replay) feed pendingTxs
// directly via BuildBlock; they do NOT route through SubmitTx, so the
// fee gate stays out of the consensus-internal path.
func (cvm *ChainVM) SubmitTx(tx []byte) error {
if err := cvm.gateUserTxBytes(tx); err != nil {
return err
}
cvm.lock.Lock()
defer cvm.lock.Unlock()
cvm.pendingTxs = append(cvm.pendingTxs, tx)
// Notify consensus that we have pending work
if cvm.toEngine != nil {
select {
case cvm.toEngine <- vm.Message{Type: vm.PendingTxs}:
default:
// Channel full, skip notification
}
}
return nil
}
// GetInnerVM returns the inner proxy VM for direct access.
func (cvm *ChainVM) GetInnerVM() *VM {
return cvm.inner
}
// WaitForEvent implements the chain.ChainVM interface.
// It blocks until an event occurs that should trigger block building.
func (cvm *ChainVM) WaitForEvent(ctx context.Context) (vm.Message, error) {
// For now, return empty message - block building is triggered via SubmitTx
// and the PendingTxs message is sent to toEngine
<-ctx.Done()
return vm.Message{}, ctx.Err()
}
-33
View File
@@ -1,33 +0,0 @@
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package main
import (
"context"
"fmt"
"os"
"github.com/luxfi/log"
"github.com/luxfi/chains/dexvm"
"github.com/luxfi/sys/ulimit"
"github.com/luxfi/vm/rpc"
)
func main() {
if len(os.Args) > 1 && os.Args[1] == "version" {
fmt.Println("DEX-VM/1.0.0")
os.Exit(0)
}
if err := ulimit.Set(ulimit.DefaultFDLimit, log.Root()); err != nil {
fmt.Fprintf(os.Stderr, "failed to set fd limit: %s\n", err)
os.Exit(1)
}
vm := dexvm.NewChainVM(log.Root())
if err := rpc.Serve(context.Background(), log.Root(), vm); err != nil {
fmt.Fprintf(os.Stderr, "rpc.Serve error: %s\n", err)
os.Exit(1)
}
}
-169
View File
@@ -1,169 +0,0 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package config defines configuration types for the DEX VM — a STATELESS
// ATOMIC ZAP PROXY. The proxy holds NO embedded-AMM configuration (no swap
// fees, pools, or order sizing): matching + DEX state live ONLY on the d-chain,
// reached over ZAP. This config covers transport (the d-chain ZAP endpoint),
// the optional Warp attestation channel, and block cadence.
package config
import (
"time"
"github.com/luxfi/ids"
)
// Config contains configuration parameters for the DEX VM proxy.
type Config struct {
// IndexAllowIncomplete enables indexing of incomplete blocks
IndexAllowIncomplete bool `json:"indexAllowIncomplete"`
// IndexTransactions enables transaction indexing
IndexTransactions bool `json:"indexTransactions"`
// ChecksumsEnabled enables merkle checksum verification
ChecksumsEnabled bool `json:"checksumsEnabled"`
// DexZapEndpoint is the node-local, version-pinned ZAP address of the
// d-chain's CLOB gateway (e.g. "127.0.0.1:9100"). The proxy forwards
// byte-identical clob_* frames here. Empty = the relay leg is inert (the
// proxy still settles via atomic import/export but has no matcher to relay
// to). MUST be node-local + version-pinned so every validator's proxy and
// its single-source-of-truth d-chain are byte-identical (consensus-safety).
DexZapEndpoint string `json:"dexZapEndpoint"`
// DexZapTimeout bounds a single ZAP relay round-trip.
DexZapTimeout time.Duration `json:"dexZapTimeout"`
// Cross-chain configuration. Warp is retained ONLY as the optional
// fill-attestation / fraud-proof channel — it is NOT the settlement
// primitive (atomic SharedMemory import/export is). TrustedChains gates
// which chains may submit attestations.
WarpEnabled bool `json:"warpEnabled"`
TrustedChains []ids.ID `json:"trustedChains"`
// FillAttestationPubKey is the venue's (d-chain matcher's) Ed25519 PUBLIC key. When
// set (non-empty, 32 bytes) it ENABLES fill-attestation enforcement: every validator
// verifies the carried-fills signature against this key over the canonical
// (blockHash, entries) message BEFORE settling, so a malicious/MITM proposer cannot
// settle FABRICATED fills (the single-proposer trust gap). A block whose carried fills
// lack a valid attestation is settled as a FULL REFUND (fail-secure). Empty = no
// enforcement (single-trusted-operator / dev) — the documented interim model. This is
// the canonical setting for an UNTRUSTED validator set (default-on once set).
//
// CONSENSUS-SAFETY (STRICTER than DexZapEndpoint): this value participates in the
// DETERMINISTIC settle decision — verifyFillAttestation gates trustCarried, which
// decides whether a block's carried fills are consumed into escrow + export legs, which
// feeds computeStateRoot. So it is recomputed inside the consensus state-transition and
// MUST be a SINGLE network-pinned constant, IDENTICAL on every validator. This is a
// STRONGER invariant than DexZapEndpoint's: DexZapEndpoint is a node-local TRANSPORT
// address used only at the proposer's build (obtainFills) and is NOT recomputed in the
// deterministic settle path, so two validators may legitimately point at distinct local
// gateways. FillAttestationPubKey may NOT vary: if validators disagreed on it they would
// reach different trust decisions for the same block and FORK the StateRoot. It is
// therefore pinned in GENESIS (vm.go Genesis.FillAttestationPubKey, assigned ONLY in
// parseGenesis) and is DELIBERATELY absent from parseConfig — runtime per-node config
// cannot set it. Distribute it as a network-upgrade-pinned constant (the same lockstep
// discipline as the carried-fills wire format), never per-node ad hoc.
FillAttestationPubKey []byte `json:"fillAttestationPubKey,omitempty"`
// FillAttestationSeed is the venue's Ed25519 SIGNING seed (32 bytes), set ONLY on a
// node that is co-located with the venue and therefore authoritative to ATTEST the
// fills it relayed (the single-operator deployment). When set, the proposer signs the
// carried fills at build so the block carries a valid attestation. It is a SECRET and
// MUST come from KMS, never a plaintext manifest. Empty on a pure validator (it only
// VERIFIES, using FillAttestationPubKey).
FillAttestationSeed []byte `json:"-"`
// --- Real-assets-only enforcement (Gate A, the green-first gate) ----------
//
// Every field's SAFE value is its zero value, so a zero-initialised config is the
// locked-down config. These are BACKEND-enforced at VM Initialize by the registry
// startup gate (registry.RefuseUnderSyntheticConfig + registry.GuardValueActivation):
// a front end cannot relax them, and a value-bearing network (mainnet/testnet) hard-
// fails startup if any synthetic flag is set or value is activated without a legal
// consensus mode. They are documented here and consumed ONLY in Initialize.
// DexAllowSyntheticAssets / DexAllowSyntheticMarkets / DexAllowMockLiquidity default
// false. true is permitted ONLY on a dev/local network (developer opt-in); on
// mainnet or testnet any true value FAILS startup (ErrSyntheticOnValueNet). The
// Liquidity / phantom / ASCII-ticker deny-scan runs on EVERY network regardless.
DexAllowSyntheticAssets bool `json:"dexAllowSyntheticAssets"`
DexAllowSyntheticMarkets bool `json:"dexAllowSyntheticMarkets"`
DexAllowMockLiquidity bool `json:"dexAllowMockLiquidity"`
// DexAllowedAssetKinds is the active allowed-kind policy as canonical tokens
// ("EVM_NATIVE","ERC20","UTXO"). Empty => the canonical default of all three. A
// configured set may only ever be a SUBSET of those three; any other token (an
// ASCII ticker, a "D_NATIVE", etc.) FAILS startup (ErrBadAllowedKind).
DexAllowedAssetKinds []string `json:"dexAllowedAssetKinds"`
// DexNativeValueEnabled gates real-money (native value) trading. Default false =
// paper/non-value mode (no value to authorise). When true, the consensus-mode value
// guard runs and REFUSES startup unless DexConsensusMode is one of exactly two legal
// value modes (see DexConsensusMode).
DexNativeValueEnabled bool `json:"dexNativeValueEnabled"`
// DexConsensusMode is the consensus posture under which native value may activate.
// Exactly two legal value modes: "QUORUM_FINALITY" (post-quantum BFT) or
// "HONEST_VALIDATOR_LABELED" (labeled CFT parity). Empty/"UNSET" with value enabled
// REFUSES startup; any unknown token REFUSES startup. There is never a silent third
// state.
DexConsensusMode string `json:"dexConsensusMode"`
// DexCapsOn / DexHaltReady are the operator-attested compensating controls required
// to activate value under HONEST_VALIDATOR_LABELED. The third leg of the launch
// bundle (real-assets-only) is NOT operator-attested — it is MACHINE-DERIVED from the
// registry being real and no synthetic flag being set, so the operator cannot lie
// about it. Both default false; under HONEST_VALIDATOR_LABELED either being false
// REFUSES value activation (ErrLaunchAssertionsUnmet).
DexCapsOn bool `json:"dexCapsOn"`
DexHaltReady bool `json:"dexHaltReady"`
// DexAssetManifestPath optionally points at the per-network real-assets manifest
// (assets.{devnet,testnet,mainnet}.json). When set, the manifest is loaded, its
// declared C-Chain / X-Chain identity is bound to the node's ACTUAL running chain
// ids (RuntimeVerifier — a real check, not a stub), its assets/markets are admitted
// into the registry, and the fail-closed startup gate runs over them. Per-token
// existence on the live net (code>0, decimals(), UTXO assetID) is proven by CI
// against the target net's RPC BEFORE the artifact ships — the node binds identity +
// enforces policy; CI proves reality. Empty + DexNativeValueEnabled=true FAILS
// startup (you cannot activate value with no declared real assets).
DexAssetManifestPath string `json:"dexAssetManifestPath"`
// DexAssetManifestSHA256 PINS the manifest to its CI-approved artifact by content hash
// (M1). When set (a 32-byte SHA-256 in hex, optional "0x"/"sha256:" prefix), the node
// REFUSES to load a manifest whose bytes do not hash to this value — an edited local
// manifest (a fabricated token address, an added asset) no longer matches and fails
// startup. The dexvm proxy holds NO EVM state and cannot eth_getCode the tokens itself,
// so this content-hash binding is what stops a tampered manifest from loading; pair it
// with the CI validate-asset-manifests workflow that proves the pinned artifact real and
// emits this hash. Empty = no pin (shape validation only) — set it in genesis/config for
// any value-bearing deployment.
DexAssetManifestSHA256 string `json:"dexAssetManifestSHA256"`
// Block configuration
BlockInterval time.Duration `json:"blockInterval"`
MaxBlockSize uint64 `json:"maxBlockSize"`
MaxTxsPerBlock uint32 `json:"maxTxsPerBlock"`
}
// DefaultConfig returns the default configuration for the DEX VM proxy.
func DefaultConfig() Config {
return Config{
IndexAllowIncomplete: false,
IndexTransactions: true,
ChecksumsEnabled: true,
// Empty by default: the proxy is inert on the relay leg until the venue
// operator points it at a d-chain gateway.
DexZapEndpoint: "",
DexZapTimeout: 5 * time.Second,
// Attestation channel off by default; the venue enables it explicitly.
WarpEnabled: false,
TrustedChains: nil,
BlockInterval: 1 * time.Millisecond, // 1ms blocks for HFT (ultra-low latency)
MaxBlockSize: 2 * 1024 * 1024, // 2MB
MaxTxsPerBlock: 10000,
}
}
-332
View File
@@ -1,332 +0,0 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// conservation_partial_test.go — the value-conservation proof under PARTIAL and
// FAILED settlement, the cases the happy-path 100%-fill test cannot reach.
//
// THE CONSERVATION LAW the atomic proxy must obey, stated as an equation over a
// single round (import -> relay -> settle -> export), measured in the asset the
// taker LOCKED on import:
//
// value_in = collateral locked by the Import (debited from C-Chain)
// value_out = proceeds exported back + unfilled collateral refunded back
// REQUIRED: value_in == value_out (conserved: nothing minted, nothing destroyed)
// WEAKER: value_out <= value_in (no-mint only; a LEAK still passes this)
//
// The happy-path test (TestEndToEndAtomicValueConservation) fills 100% at
// price 1, so value_in == value_out trivially and the weaker no-mint bound is
// indistinguishable from true conservation. These tests separate the two: on a
// partial or zero fill the locked collateral exceeds the realized proceeds, so a
// proxy that does not REFUND the unfilled remainder destroys value while still
// satisfying the no-mint bound. The taker's exported credit is measured directly
// from C-Chain shared memory after accept (server-side truth), never asserted.
//
// These run under `GOWORK=off CGO_ENABLED=0 go test ./...` — pure Go, in-memory
// two-chain atomic.SharedMemory, fake d-chain matcher via the zapDialer seam.
package dexvm
import (
"context"
"math/big"
"testing"
"time"
"github.com/luxfi/ids"
"github.com/luxfi/vm/chains/atomic"
"github.com/luxfi/chains/dexvm/txs"
)
// settledRound drives one full import -> relay(clob_submit) -> accept round on a
// fresh harness whose fake matcher returns exactly `fills`, with the taker
// locking `collateral` units of `lockedAsset` on the import leg. It returns the
// TOTAL value the C-Chain can claim back afterwards (proceeds + any refund),
// summed across every exported element owned by the taker. This is the measured
// `value_out` of the conservation equation.
func settledRound(t *testing.T, fills []Fill, collateral uint64, takerSide uint8) (valueIn, valueOut uint64) {
t.Helper()
// The matcher must echo the taker side on every fill (the proxy reads the
// settlement direction from fills[0].Side), so stamp it.
stamped := make([]Fill, len(fills))
for i, f := range fills {
f.Side = takerSide
stamped[i] = f
}
h := newConservationHarness(t, stamped)
ctx := context.Background()
taker := ids.GenerateTestShortID()
h.exportTaker = taker
lockedAsset := ids.GenerateTestID()
// C-Chain exports `collateral` units into shared memory for the proxy to import.
srcUTXOID := deriveUTXOID(ids.GenerateTestID(), 0)
exportedVal := encodeExportedOutput(txs.AtomicOutput{Owner: taker, Asset: lockedAsset, Amount: collateral})
if err := h.cChainSM.Apply(map[ids.ID]*atomic.Requests{
h.proxyChain: {PutRequests: []*atomic.Element{{
Key: srcUTXOID[:],
Value: exportedVal,
Traits: [][]byte{taker[:]},
}}},
}); err != nil {
t.Fatalf("seed C-Chain export: %v", err)
}
// import (locks `collateral`) -> relay clob_submit (matcher returns `fills`).
importTx := newImportTxBytes(t, taker, h.cChain, srcUTXOID, lockedAsset, collateral)
// size in the submit frame is the taker's requested size; the matcher ignores
// it and returns the canned fills, so any positive value is fine here.
relayTx := newRelayTxBytes(t, taker, srcUTXOID, clobSubmitPayload(lockedAsset, collateral))
// Propose (relay ONCE at build, carry the fills) + accept (settle from carried
// bytes). RED #9: the relay moved from accept to build; the conservation
// arithmetic at settle is unchanged.
_ = ctx
proposeAndAccept(t, h.vm, 1, time.Unix(1, 0), [][]byte{importTx, relayTx})
return collateral, exportedTotal(t, h)
}
// fillNotional returns the SPENT quantity a fill stream consumes from the asset
// the taker LOCKED: a BUY locks quote and the realized quote spent is
// sum(price*size); a SELL locks base and the realized base sold is sum(size).
// This is the amount that legitimately leaves as the *other* asset's proceeds —
// the rest of the locked collateral is the unfilled remainder conservation
// requires be refunded.
//
// It mirrors settleFromFills exactly: the spent quantity is aggregated over the
// fills as EXACT integers — a BUY's quote consumed is Σ floor(size × price /
// PriceScale) (the venue matcher's own per-fill quote), a SELL's base consumed is
// Σ size. There is no float and no rounding seam: the proxy spends precisely what
// the venue matched, so refund = locked - spent is exact and never inflated.
func fillNotional(fills []Fill, takerSide uint8) uint64 {
total := new(big.Int)
for _, f := range fills {
if takerSide == 0 { // BUY locked quote; quote consumed = Σ floor(size*price/PriceScale)
total.Add(total, quoteUnits(f.Size, f.Price))
} else { // SELL locked base; base consumed = Σ size
total.Add(total, new(big.Int).SetUint64(f.Size))
}
}
return total.Uint64()
}
// TestConservationPartialFillBuy is the decisive partial-settlement proof. The
// taker locks 1000 quote and submits a marketable BUY, but the book only fills
// 400 quote worth (200 base @ price 2). Conservation REQUIRES the C-Chain get
// back 1000 total — 200 base of proceeds PLUS 600 quote refunded. The proxy
// credits only the 200-base proceeds leg, so 600 quote of locked collateral is
// destroyed. The test states the law and prints the measured imbalance.
func TestConservationPartialFillBuy(t *testing.T) {
const lockedQuote uint64 = 1000
// One fill: 200 base @ price 2 => 400 quote spent, 600 quote should refund.
fills := []Fill{{Price: fp(2), Size: 200}}
const takerSide uint8 = 0 // BUY
valueIn, valueOut := settledRound(t, fills, lockedQuote, takerSide)
spentQuote := fillNotional(fills, takerSide) // 400
wantRefund := valueIn - spentQuote // 600
// Proceeds the taker received, denominated in the proceeds asset (base). The
// taker's exported credit is split across two assets (base proceeds + quote
// refund) but exportedTotal sums raw amounts; we reason in conservation units.
t.Logf("CONSERVATION LEDGER (BUY, partial): value_in(locked quote)=%d spent_quote=%d required_refund=%d measured_value_out(total exported to taker)=%d",
valueIn, spentQuote, wantRefund, valueOut)
// No-mint bound MUST always hold (the proxy never fabricates value).
if valueOut > valueIn {
t.Fatalf("NO-MINT VIOLATED: value_out=%d > value_in=%d", valueOut, valueIn)
}
// Full conservation: every locked unit comes back either as proceeds or refund.
// value_out is base-proceeds(200) + quote-refund(600) = 800 if the refund leg
// exists; 200 (proceeds only) if it does not. We assert the conservation law:
// value_out must equal proceeds(=200 base) + refund(=600 quote) = 800.
const wantProceedsBase uint64 = 200
wantOut := wantProceedsBase + wantRefund // 800
if valueOut != wantOut {
t.Fatalf("CONSERVATION VIOLATED on partial fill: measured value_out=%d, want proceeds(%d base)+refund(%d quote)=%d. "+
"The %d-unit gap is locked collateral neither filled nor refunded — value DESTROYED in the proxy.",
valueOut, wantProceedsBase, wantRefund, wantOut, wantOut-valueOut)
}
}
// TestConservationZeroFill is the strongest case: a marketable order that
// crosses NOTHING. The taker locked 1000 quote; the matcher returns zero fills.
// Conservation REQUIRES the full 1000 be refunded to C-Chain. A proxy with no
// refund leg exports nothing, destroying the entire locked collateral.
func TestConservationZeroFill(t *testing.T) {
const lockedQuote uint64 = 1000
valueIn, valueOut := settledRound(t, nil, lockedQuote, 0)
t.Logf("CONSERVATION LEDGER (zero fill): value_in(locked quote)=%d required_refund=%d measured_value_out=%d",
valueIn, valueIn, valueOut)
if valueOut > valueIn {
t.Fatalf("NO-MINT VIOLATED: value_out=%d > value_in=%d", valueOut, valueIn)
}
if valueOut != valueIn {
t.Fatalf("CONSERVATION VIOLATED on zero fill: measured value_out=%d, want full refund=%d. "+
"%d units of locked collateral were DESTROYED (no fills, no refund export).",
valueOut, valueIn, valueIn-valueOut)
}
}
// TestConservationPartialFillSell mirrors the partial-fill proof for a SELL: the
// taker locks 1000 BASE and submits a marketable sell, but only 300 base fills
// (300 base @ price 5 => 1500 quote proceeds). Conservation REQUIRES 700 base
// refunded plus 1500 quote proceeds. Measured in conservation units, value_out
// must be proceeds(1500 quote) + refund(700 base) = 2200.
func TestConservationPartialFillSell(t *testing.T) {
const lockedBase uint64 = 1000
fills := []Fill{{Price: fp(5), Size: 300}} // 300 base sold, 1500 quote received
const takerSide uint8 = 1 // SELL
valueIn, valueOut := settledRound(t, fills, lockedBase, takerSide)
soldBase := fillNotional(fills, takerSide) // 300
wantRefund := valueIn - soldBase // 700
const wantProceedsQuote uint64 = 1500
wantOut := wantProceedsQuote + wantRefund // 2200
t.Logf("CONSERVATION LEDGER (SELL, partial): value_in(locked base)=%d sold_base=%d required_refund=%d proceeds_quote=%d measured_value_out=%d want=%d",
valueIn, soldBase, wantRefund, wantProceedsQuote, valueOut, wantOut)
if valueOut != wantOut {
t.Fatalf("CONSERVATION VIOLATED on partial sell: value_out=%d, want proceeds(%d quote)+refund(%d base)=%d (gap=%d destroyed)",
valueOut, wantProceedsQuote, wantRefund, wantOut, wantOut-valueOut)
}
}
// TestSettleConsumesEscrowOnce proves the refund leg cannot double-pay: once a
// settle has refunded the unfilled remainder, the escrow is consumed, so a
// second settle against the same collateral ref refunds NOTHING (it would
// otherwise mint a duplicate refund). This is the return-leg analogue of the
// import double-spend guard.
func TestSettleConsumesEscrowOnce(t *testing.T) {
h := newConservationHarness(t, nil)
taker := ids.GenerateTestShortID()
lockedAsset := ids.GenerateTestID()
ref := deriveUTXOID(ids.GenerateTestID(), 0)
// Record an escrow of 1000 directly (the import leg's effect). The escrow owner
// is the taker — the authenticated owner who alone may settle it (CRITICAL bind).
if err := h.vm.state.PutEscrow(ref, taker, lockedAsset, 1000); err != nil {
t.Fatalf("PutEscrow: %v", err)
}
// First settle with zero fills => full 1000 refund + escrow consumed. No proceeds
// leg (zero fills), so the output asset / price limit are irrelevant here.
ar := newAtomicRequests()
if err := h.vm.settleFromFills(taker, ref, nil, ids.Empty, 0, false, ids.GenerateTestID(), 0, ar); err != nil {
t.Fatalf("first settle: %v", err)
}
if _, _, _, found, _ := h.vm.state.GetEscrow(ref); found {
t.Fatal("escrow still present after settle — not consumed")
}
// Second settle against the now-consumed ref must refund nothing (no escrow).
ar2 := newAtomicRequests()
if err := h.vm.settleFromFills(taker, ref, nil, ids.Empty, 0, false, ids.GenerateTestID(), 1, ar2); err != nil {
t.Fatalf("second settle: %v", err)
}
if !ar2.empty() {
t.Fatal("second settle produced an export — escrow was double-refunded (value minted)")
}
}
// TestFailedRelayCommitsNothing proves CONSERVATION on a FAILED build relay (RED
// #9). The relay is performed once by the PROPOSER at build (obtainFills); if it
// errors, the proposer carries an explicit ZERO-FILL entry for that order. Every
// validator then settles that as a full refund of the locked collateral — the
// taker's value returns to C-Chain in full, the escrow is consumed exactly once,
// and nothing is minted or destroyed. (This is the same conservation-safe path as
// a legitimate zero-fill submit; see TestConservationZeroFill.)
//
// This is STRICTLY stronger than the old relay-at-accept behavior (escrow left in
// limbo for a later retry): the value is returned immediately rather than stranded
// in escrow, and there is no per-validator divergence because the zero-fill is
// carried in the block bytes, settled identically by every node.
func TestFailedRelayCommitsNothing(t *testing.T) {
h := newConservationHarness(t, nil)
taker := ids.GenerateTestShortID()
h.exportTaker = taker
lockedAsset := ids.GenerateTestID()
// Make the matcher fail the build relay (transport failure on the relay leg).
h.matcher.failNext = true
// Seed + import 1000 collateral, then relay (which will fail at the matcher).
srcUTXOID := deriveUTXOID(ids.GenerateTestID(), 0)
exportedVal := encodeExportedOutput(txs.AtomicOutput{Owner: taker, Asset: lockedAsset, Amount: 1000})
if err := h.cChainSM.Apply(map[ids.ID]*atomic.Requests{
h.proxyChain: {PutRequests: []*atomic.Element{{Key: srcUTXOID[:], Value: exportedVal, Traits: [][]byte{taker[:]}}}},
}); err != nil {
t.Fatalf("seed export: %v", err)
}
importTx := newImportTxBytes(t, taker, h.cChain, srcUTXOID, lockedAsset, 1000)
relayTx := newRelayTxBytes(t, taker, srcUTXOID, clobSubmitPayload(lockedAsset, 1000))
// Propose (build relay fails -> zero fills carried) + accept (settle -> full
// refund of the 1000 locked collateral).
proposeAndAccept(t, h.vm, 1, time.Unix(1, 0), [][]byte{importTx, relayTx})
// CONSERVATION: the full locked collateral is refunded to C-Chain (no fills =>
// no proceeds, refund == locked). Value is preserved exactly — nothing minted,
// nothing destroyed.
if got := exportedTotal(t, h); got != 1000 {
t.Fatalf("failed build relay refunded %d to C-Chain, want 1000 (full refund of locked collateral)", got)
}
// The escrow is consumed exactly once by the refunding settle (it cannot be
// refunded again).
_, _, _, found, _ := h.vm.state.GetEscrow(srcUTXOID)
if found {
t.Fatalf("escrow after refund must be consumed exactly once, but it is still present")
}
}
// TestConservationRoundingBoundary probes the exact-integer quote seam in the
// spent/refund split. settleFromFills aggregates the quote notional over the fills
// as EXACT integers, flooring EACH fill's quote = floor(size × price / PriceScale)
// exactly as the venue matcher did — so the proxy spends precisely what the venue
// moved, with no float and no directional rounding. Two fractional-priced fills
// (price 1.5 × size 3 → floor(4.5)=4; price 2.5 × size 3 → floor(7.5)=7) sum to
// spent = 4+7 = 11 (the venue's own per-fill quote, NOT the old float-aggregate
// ceil of 12). Proceeds base = Σ size = 6 (whole base units). The taker locked 1000
// quote; refund = 1000-11 = 989, and value_out = base-proceeds(6) +
// quote-refund(989) = 995. The refund and proceeds legs agree on the SAME integer
// notional the venue used, so no sub-unit value leaks in either direction.
func TestConservationRoundingBoundary(t *testing.T) {
const lockedQuote uint64 = 1000
fills := []Fill{{Price: fp(1.5), Size: 3}, {Price: fp(2.5), Size: 3}} // BUY
const takerSide uint8 = 0
valueIn, valueOut := settledRound(t, fills, lockedQuote, takerSide)
// SPENT quote = Σ floor(size×price/PriceScale), the venue's own per-fill quote.
spent := fillNotional(fills, takerSide)
// PROCEEDS base = Σ size (whole atomic base units).
var proceedsBase uint64
for _, f := range fills {
proceedsBase += f.Size
}
wantRefund := lockedQuote - spent
wantOut := proceedsBase + wantRefund
t.Logf("CONSERVATION LEDGER (integer quote): value_in=%d spent(Σfloor)=%d proceeds_base=%d refund=%d measured_value_out=%d want=%d",
valueIn, spent, proceedsBase, wantRefund, valueOut, wantOut)
if spent != 11 || proceedsBase != 6 {
t.Fatalf("arithmetic anchor drifted: spent=%d (want 11) proceeds_base=%d (want 6)", spent, proceedsBase)
}
if valueOut != wantOut {
t.Fatalf("CONSERVATION VIOLATED at rounding boundary: value_out=%d, want %d. "+
"refund and proceeds legs disagree on the rounded notional — sub-unit value leaked.", valueOut, wantOut)
}
if valueOut > valueIn {
t.Fatalf("NO-MINT VIOLATED: value_out=%d > value_in=%d", valueOut, valueIn)
}
}
-414
View File
@@ -1,414 +0,0 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// conservation_test.go — the end-to-end value-conservation proof for the
// stateless atomic ZAP proxy. This is the ONE acceptance criterion that had no
// executable proof before: move value C-Chain -> proxy -> d-chain and back, and
// assert GLOBAL ALL-OR-NOTHING CONSERVATION — the proxy never mints, never
// burns value into nowhere; every credited unit traces to a confirmed fill or
// to value that was locked on the import leg.
//
// The two primitives are exercised end-to-end with REAL atomic.SharedMemory
// (in-memory two-chain harness) for the settlement leg and a fake ZAP listener
// (the zapDialer seam) standing in for the d-chain matcher on the relay leg.
package dexvm
import (
"context"
"encoding/binary"
"testing"
"time"
"github.com/luxfi/database/memdb"
"github.com/luxfi/database/prefixdb"
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/runtime"
"github.com/luxfi/vm"
"github.com/luxfi/vm/chains/atomic"
"github.com/luxfi/warp"
"github.com/luxfi/chains/dexvm/config"
"github.com/luxfi/chains/dexvm/txs"
)
// fakeMatcherConn is a fake d-chain matcher over the zapConn seam. A clob_submit
// returns a canned fills frame; place/cancel return an ack. It records every
// frame it received so the test can assert the proxy forwarded byte-identical
// payloads.
type fakeMatcherConn struct {
fills []Fill
received [][]byte
// failNext makes the next Call return a transport error, simulating a
// d-chain relay failure for the atomicity-on-failed-settle proof.
failNext bool
}
func (f *fakeMatcherConn) Call(_ context.Context, method string, payload []byte) ([]byte, error) {
if f.failNext {
f.failNext = false
return nil, context.DeadlineExceeded
}
f.received = append(f.received, append([]byte(nil), payload...))
switch method {
case ZAPMethodSubmit:
return encodeFillsWire(f.fills), nil
default:
// ack: order_id(8) + status(0=placed) + seq(8)
ack := make([]byte, 17)
binary.BigEndian.PutUint64(ack[0:8], 1)
return ack, nil
}
}
func (f *fakeMatcherConn) Close() error { return nil }
// encodeFillsWire encodes fills in the FROZEN clob_submit response format:
// count[4] then count×(price[8]+size[8]+side[1]), price a uint64 fixed-point
// ×PriceScale value and size a uint64 atomic-base-unit count — the exact-integer
// inverse of DecodeFills.
func encodeFillsWire(fills []Fill) []byte {
resp := make([]byte, 4+len(fills)*FillWireSize)
binary.BigEndian.PutUint32(resp[0:4], uint32(len(fills)))
off := 4
for _, fl := range fills {
binary.BigEndian.PutUint64(resp[off:off+8], fl.Price)
binary.BigEndian.PutUint64(resp[off+8:off+16], fl.Size)
resp[off+16] = fl.Side
off += FillWireSize
}
return resp
}
// fp scales a human quote-per-base price onto the wire's fixed-point ×PriceScale
// grid (the SAME conversion dex's wirePriceUnits applies), so a test keeps readable
// human prices while the Fill/wire carries the exact-integer PriceInt value. quote =
// size × fp(price) / PriceScale, so for whole human prices the settled quote equals
// the old human price × size exactly.
func fp(human float64) uint64 { return uint64(human * float64(PriceScale)) }
// conservationHarness wires a proxy VM with REAL two-chain shared memory and a
// fake matcher, returning the proxy VM, the C-Chain's shared-memory handle, and
// the chain ids.
type conservationHarness struct {
vm *VM
cChainSM atomic.SharedMemory
proxyChain ids.ID
cChain ids.ID
matcher *fakeMatcherConn
exportTaker ids.ShortID // the address whose exported proceeds we audit
}
func newConservationHarness(t *testing.T, fills []Fill) *conservationHarness {
t.Helper()
logger := log.NewNoOpLogger()
baseDB := memdb.New()
memoryDB := prefixdb.New([]byte{0}, baseDB)
m := atomic.NewMemory(memoryDB)
proxyChain := ids.GenerateTestID()
cChain := ids.GenerateTestID()
proxySM := m.NewSharedMemory(proxyChain)
cChainSM := m.NewSharedMemory(cChain)
matcher := &fakeMatcherConn{fills: fills}
// Drive the relay over the fake matcher instead of a live socket.
prev := zapDialer
zapDialer = func(_ context.Context, _ string) (zapConn, error) { return matcher, nil }
t.Cleanup(func() { zapDialer = prev })
rt := &runtime.Runtime{
ChainID: proxyChain,
CChainID: cChain,
NetworkID: 96369,
Log: logger,
SharedMemory: proxySM,
}
v := &VM{}
// Non-empty endpoint so the relay leg is live; the fake dialer ignores it.
v.Config = config.DefaultConfig()
v.Config.DexZapEndpoint = "127.0.0.1:0"
if err := v.Initialize(context.Background(), vm.Init{
Runtime: rt,
DB: prefixdb.New([]byte{1}, baseDB),
ToEngine: make(chan vm.Message, 8),
Sender: warp.FakeSender{},
Log: logger,
}); err != nil {
t.Fatalf("init proxy vm: %v", err)
}
v.bootstrapped = true
return &conservationHarness{
vm: v,
cChainSM: cChainSM,
proxyChain: proxyChain,
cChain: cChain,
matcher: matcher,
}
}
// ---------------------------------------------------------------------------
// Test helpers.
// ---------------------------------------------------------------------------
// proposeAndAccept models the production PROPOSER flow for a single node (RED #9):
// build the block (BuildBlockResult performs the network-wide-ONCE d-chain relay
// via obtainFills and carries the confirmed fills) and then accept it (settle
// purely from those carried fills). This is the only path that triggers a relay;
// Verify/Accept never do. Direct-call tests use it wherever they previously relied
// on acceptBlock relaying — the relay LOCATION moved from accept to build, the
// conservation invariants are unchanged.
func proposeAndAccept(t *testing.T, vm *VM, height uint64, blockTime time.Time, txBytes [][]byte) *BlockResult {
t.Helper()
ctx := context.Background()
result, err := vm.BuildBlockResult(ctx, height, blockTime, txBytes)
if err != nil {
t.Fatalf("BuildBlockResult: %v", err)
}
if err := vm.acceptBlock(ctx, result); err != nil {
t.Fatalf("acceptBlock: %v", err)
}
return result
}
// newImportTxBytes builds a wire-ready ImportTx claiming one source UTXO.
func newImportTxBytes(t *testing.T, from ids.ShortID, sourceChain, utxoID, asset ids.ID, amount uint64) []byte {
t.Helper()
tx := txs.NewImportTx(from, 0, sourceChain,
[]txs.AtomicInput{{UTXOID: utxoID, Asset: asset, Amount: amount}},
[]txs.AtomicOutput{{Owner: from, Asset: asset, Amount: amount}},
)
return tx.Bytes()
}
// newRelayTxBytes builds a wire-ready RelayOrderTx carrying a clob_submit frame.
// It declares NO output asset (ids.Empty) — sufficient for tests that assert only
// conservation TOTALS (which sum every exported asset). A test that asserts the
// proceeds ASSET (the HIGH-1 happy path) uses newSettlingRelayTxBytes.
func newRelayTxBytes(t *testing.T, from ids.ShortID, collateralRef ids.ID, payload []byte) []byte {
t.Helper()
return txs.NewRelayOrderTx(from, 0, ZAPMethodSubmit, payload, collateralRef).Bytes()
}
// newSettlingRelayTxBytes builds a wire-ready settling clob_submit RelayOrderTx
// carrying the REAL output-asset id (the HIGH-1 fix: the proceeds leg is exported
// under THIS id, the same one the C-side ImportSettlement requires) and the taker's
// slippage price limit (0 = none). This is the canonical settling-relay the keeper
// builds once it has resolved the market's output side.
func newSettlingRelayTxBytes(t *testing.T, from ids.ShortID, collateralRef, assetOut ids.ID, payload []byte, priceLimit uint64, limitIsUpper bool) []byte {
t.Helper()
return txs.NewSettlingRelayOrderTx(from, 0, payload, collateralRef, assetOut, priceLimit, limitIsUpper).Bytes()
}
// mustParseImport parses wire bytes back into a typed ImportTx.
func mustParseImport(t *testing.T, b []byte) *txs.ImportTx {
t.Helper()
parser := &txs.TxParser{}
tx, err := parser.Parse(b)
if err != nil {
t.Fatalf("parse import: %v", err)
}
return tx.(*txs.ImportTx)
}
// clobSubmitPayload builds the FROZEN 66-byte clob_submit request frame:
// poolId[32] | side[1] | isMarket[1] | limit[8] | size[8] | user[16]. size is a
// uint64 atomic-base-unit count written directly (no float on the wire).
func clobSubmitPayload(poolID ids.ID, size uint64) []byte {
p := make([]byte, 66)
copy(p[0:32], poolID[:])
p[32] = 0 // buy
p[33] = 1 // market
binary.BigEndian.PutUint64(p[42:50], size)
return p
}
// exportedTotal sums the value the proxy exported back to C-Chain (the proceeds
// the C-Chain can now claim), read via the C-Chain's Indexed view on the taker
// trait. Each element value is owner(20)|asset(32)|amount(8).
func exportedTotal(t *testing.T, h *conservationHarness) uint64 {
t.Helper()
var total uint64
for _, v := range indexedByTaker(t, h) {
if len(v) >= exportedOutputSize {
total += binary.BigEndian.Uint64(v[53:61])
}
}
return total
}
// indexedByTaker returns the exported proceeds the C-Chain can claim, indexed by
// the taker address trait.
func indexedByTaker(t *testing.T, h *conservationHarness) [][]byte {
t.Helper()
// The proxy used the taker (sender) as the output owner/trait. Recover it
// from the consumed receipts is overkill; the taker is the single sender we
// used. Use the well-known taker from the test by scanning all proceeds.
// Indexed needs the trait; we pass the taker address recorded on the export.
taker := h.exportTaker
vals, _, _, err := h.cChainSM.Indexed(h.proxyChain, [][]byte{taker[:]}, nil, nil, 100)
if err != nil {
t.Fatalf("Indexed C-Chain proceeds: %v", err)
}
return vals
}
// The conservation test.
// ---------------------------------------------------------------------------
// TestEndToEndAtomicValueConservation moves value C-Chain -> proxy -> d-chain
// and back, asserting GLOBAL ALL-OR-NOTHING CONSERVATION across the boundary:
//
// 1. C-Chain exports 1000 units into shared memory (the proxy can claim them).
// 2. The proxy IMPORTS them (consumes the UTXO atomically) — debit leg.
// 3. The proxy RELAYS a clob_submit to the d-chain (fake matcher) which returns
// fills totaling exactly the imported notional.
// 4. The proxy EXPORTS the proceeds back to C-Chain (credit leg), derived ONLY
// from the returned fills.
// 5. Assert: the source UTXO is GONE from shared memory (consumed exactly
// once), the proxy minted NOTHING (exported base+quote == fill totals), and
// C-Chain can claim exactly the exported proceeds.
func TestEndToEndAtomicValueConservation(t *testing.T) {
// Matcher fills: 600 base @ price 1 + ... summing to a known notional. Use a
// single fill of 1000 base @ price 1 so base=1000, quote=1000 (integer-exact).
fills := []Fill{{Price: fp(1), Size: 1000, Side: 0}}
h := newConservationHarness(t, fills)
taker := ids.GenerateTestShortID()
h.exportTaker = taker
assetID := ids.GenerateTestID()
// --- Step 1: C-Chain exports 1000 units into shared memory. ---
// The exported UTXO key is what the proxy's import will consume.
srcTxID := ids.GenerateTestID()
srcUTXOID := deriveUTXOID(srcTxID, 0)
exportedVal := encodeExportedOutput(txs.AtomicOutput{Owner: taker, Asset: assetID, Amount: 1000})
if err := h.cChainSM.Apply(map[ids.ID]*atomic.Requests{
h.proxyChain: {PutRequests: []*atomic.Element{{
Key: srcUTXOID[:],
Value: exportedVal,
Traits: [][]byte{taker[:]},
}}},
}); err != nil {
t.Fatalf("C-Chain export to shared memory: %v", err)
}
// Sanity: the proxy can see the exported value before importing it.
vals, err := h.vm.sharedMemory().Get(h.cChain, [][]byte{srcUTXOID[:]})
if err != nil || len(vals) != 1 || vals[0] == nil {
t.Fatalf("proxy cannot see exported UTXO: vals=%v err=%v", vals, err)
}
// --- Steps 2-4: import -> relay -> export, all in one block. ---
collateralRef := srcUTXOID
importTx := newImportTxBytes(t, taker, h.cChain, srcUTXOID, assetID, 1000)
relayTx := newRelayTxBytes(t, taker, collateralRef, clobSubmitPayload(assetID, 1000))
// --- Steps 3+4: propose (relay ONCE at build, carry fills) + accept (settle). ---
// This is the production proposer flow. The relay happens exactly once here for
// the whole network and the fills are carried in the block bytes; Verify and
// Accept never relay (that purity is pinned by TestRED_VerifyDoesNotRelay and
// TestRED_PerValidatorRelay_SplitsConsensus). Verifying the block bytes does not
// re-consume the import — BuildBlockResult runs the single Verify pass.
_ = proposeAndAccept(t, h.vm, 1, time.Unix(1, 0), [][]byte{importTx, relayTx})
// The proposer forwarded exactly one byte-identical clob_submit frame at build.
if len(h.matcher.received) != 1 {
t.Fatalf("matcher received %d frames after propose+accept, want 1 (one clob_submit at build)", len(h.matcher.received))
}
if got := len(h.matcher.received[0]); got != 66 {
t.Fatalf("relayed clob_submit frame = %d bytes, want 66", got)
}
// --- Step 5: assert GLOBAL ALL-OR-NOTHING CONSERVATION. ---
const importedValue uint64 = 1000
// (a) The source UTXO was consumed exactly once — gone from shared memory.
after, err := h.vm.sharedMemory().Get(h.cChain, [][]byte{srcUTXOID[:]})
if err == nil && len(after) == 1 && after[0] != nil {
t.Fatalf("source UTXO still present after import — value was NOT consumed (double-spend risk)")
}
// (b) The taker BUY received exactly the filled base (single-leg
// conservation): one fill of 1000 base @ price 1 -> received base = 1000.
var wantReceived uint64
for _, f := range fills {
wantReceived += uint64(f.Size) // BUY receives base = sum(size)
}
gotExported := exportedTotal(t, h)
if gotExported != wantReceived {
t.Errorf("exported proceeds = %d, want %d (must equal confirmed fills exactly)", gotExported, wantReceived)
}
// (c) THE conservation invariant: the proxy never mints. Total value exported
// back to C-Chain must NOT exceed the value imported from C-Chain. (Equality
// here because the whole locked notional filled; any unfilled remainder would
// make this strictly less, with the difference refunded as the locked asset.)
if gotExported > importedValue {
t.Fatalf("CONSERVATION VIOLATED: exported %d > imported %d (proxy minted value)", gotExported, importedValue)
}
// (d) The consumed set records the source UTXO (replay protection persists).
consumed, err := h.vm.state.IsConsumed(srcUTXOID)
if err != nil {
t.Fatalf("IsConsumed: %v", err)
}
if !consumed {
t.Error("source UTXO not in consumed set after import")
}
}
// TestImportRefusesDoubleSpend proves a source UTXO can be claimed at most once:
// a second import of the same UTXO fails, so the proxy can never mint by
// re-importing already-claimed value.
func TestImportRefusesDoubleSpend(t *testing.T) {
h := newConservationHarness(t, nil)
ctx := context.Background()
taker := ids.GenerateTestShortID()
assetID := ids.GenerateTestID()
utxoID := deriveUTXOID(ids.GenerateTestID(), 0)
// Seed the source UTXO into shared memory (the realistic precondition): the
// import binds its declared asset/amount to this RECORDED value, so the UTXO
// must exist and match. (The asset-bind fix means an import of a non-existent
// UTXO is rejected as unbacked — exercised by the custody asset-bind tests.)
if err := h.cChainSM.Apply(map[ids.ID]*atomic.Requests{
h.proxyChain: {PutRequests: []*atomic.Element{{
Key: utxoID[:],
Value: encodeExportedOutput(txs.AtomicOutput{Owner: taker, Asset: assetID, Amount: 500}),
Traits: [][]byte{taker[:]},
}}},
}); err != nil {
t.Fatalf("seed source UTXO: %v", err)
}
ar := newAtomicRequests()
tx := mustParseImport(t, newImportTxBytes(t, taker, h.cChain, utxoID, assetID, 500))
if err := h.vm.executeImport(tx, ar); err != nil {
t.Fatalf("first import: %v", err)
}
// Second import of the same UTXO must fail (double-spend guard).
if err := h.vm.executeImport(tx, ar); err == nil {
t.Fatal("second import of same UTXO succeeded — double-spend NOT prevented")
}
_ = ctx
}
// TestRelayInertWithoutEndpoint proves the relay leg is inert when no d-chain
// endpoint is configured: a relay returns ErrRelayNotConfigured and never
// panics, never mints.
func TestRelayInertWithoutEndpoint(t *testing.T) {
relay := NewRelayClient("", 0)
if relay.Configured() {
t.Fatal("empty-endpoint relay reports Configured()=true")
}
if _, err := relay.Relay(context.Background(), ZAPMethodSubmit, []byte{0}); err != ErrRelayNotConfigured {
t.Fatalf("inert relay returned %v, want ErrRelayNotConfigured", err)
}
}
-238
View File
@@ -1,238 +0,0 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dexvm
import (
"context"
"encoding/binary"
"fmt"
"github.com/luxfi/ids"
"github.com/luxfi/chains/dexvm/txs"
)
// custody.go is the FUNDS-IN / FUNDS-OUT rail that connects the proxy's atomic
// shared-memory leg (atomic.go) to the D-Chain balance ledger (where the money
// lives in the order book). It is the CLOB deposit/withdraw model, decomplected
// into the two primitives the proxy already owns plus a single relay:
//
// DEPOSIT = atomic IMPORT (debit C/X, consume the UTXO exactly once) THEN
// relay clob_deposit (credit the account's available D-Chain balance).
// The credited D-Chain amount EQUALS the imported value — no escrow,
// no settlement math; the value simply MOVES into the book's ledger.
//
// WITHDRAW = relay clob_withdraw (debit the account's realized D-Chain balance,
// returning the REALIZED amount the ledger actually released) THEN
// atomic EXPORT of exactly that realized amount (credit C/X).
// A withdraw can only export realized, un-escrowed balance, so the
// exported value never exceeds what the ledger debited — no mint.
//
// CONSERVATION (the whole point): every unit that leaves C/X on a deposit import
// is credited into the D-Chain ledger; every unit the ledger releases on a
// withdraw is exported back to C/X. Across deposit -> (trade inside the D-Chain)
// -> withdraw, C/X value out == C/X value in for each account, to the unit. The
// trade itself conserves inside the D-Chain ledger (dchain.settleFills moves
// maker<->taker exact integers); the proxy never mints because it only ever
// imports (consume-once) and exports (realized-only).
//
// CONSENSUS NOTE (the relay LOCATION): exactly like the order relay (RED #9),
// the irreversible/non-deterministic D-Chain relay for deposit/withdraw must run
// ONCE network-wide at the PROPOSER's BuildBlock and have its result CARRIED in
// the block bytes, so every validator settles the atomic leg from carried bytes
// (never a per-validator relay). The primitives here are the relay+atomic
// composition; wiring them into BuildBlock's carried-result plan (a withdraw
// carries its realized amount; a deposit carries only an ack) is the same
// proposer-once+carry discipline obtainFills/settleCarried already implement for
// submits, and the block/vertex wire that carries them is the network-upgrade-
// gated change. These functions are written to be driven from that plan: deposit
// returns nothing to carry (idempotent credit), withdraw returns the realized
// amount to carry into the export leg.
// userHandle renders a 20-byte account address to the 16-byte user identity the
// frozen CLOB frames carry (the leading 16 bytes of the address, the same width
// zapwire.UserSize uses). The D-Chain folds this 16-byte user to its 8-byte
// UserID; binding the proxy's deposit/withdraw to the same 16-byte slice keeps
// the credited/debited account consistent with the orders that account places.
func userHandle(addr ids.ShortID) string {
return string(addr[:])
}
// encodeDepositFrame builds the FROZEN clob_deposit payload: user[16] + asset[32]
// + amount[8] + ref[32]. Byte-identical with
// github.com/luxfi/dex/pkg/zapwire.EncodeDeposit (the proxy cannot import the
// cgo-tagged d-chain package; the hardcoded-canonical sizes move in lockstep).
// asset is the FULL 32-byte injective cross-chain id (native == ids.Empty), keyed
// at full width on the D-Chain ledger so distinct assets never collide. ref is the
// originating-tx idempotency reference (this rail uses the consumed import's tx id)
// the D-Chain folds into its seen: dedup key.
func encodeDepositFrame(user string, asset ids.ID, amount uint64, ref ids.ID) []byte {
out := make([]byte, DepositReqSize)
copy(out[0:16], padUser(user))
copy(out[16:custodyAmountOff], asset[:])
binary.BigEndian.PutUint64(out[custodyAmountOff:custodyRefOff], amount)
copy(out[custodyRefOff:custodyRefOff+RefSize], ref[:])
return out
}
// encodeWithdrawFrame builds the FROZEN clob_withdraw payload: user[16] +
// asset[32] + amount[8] + ref[32]. asset is the FULL 32-byte injective cross-chain
// id (native == ids.Empty). ref is the originating-tx idempotency reference (this
// rail uses the settlement fillRef) the D-Chain folds into its seen: key.
func encodeWithdrawFrame(user string, asset ids.ID, amount uint64, ref ids.ID) []byte {
out := make([]byte, WithdrawReqSize)
copy(out[0:16], padUser(user))
copy(out[16:custodyAmountOff], asset[:])
binary.BigEndian.PutUint64(out[custodyAmountOff:custodyRefOff], amount)
copy(out[custodyRefOff:custodyRefOff+RefSize], ref[:])
return out
}
// padUser left-copies a user identity into a fresh 16-byte (UserSize) buffer,
// null-padded — the frozen frame's user field encoding.
func padUser(user string) []byte {
b := make([]byte, 16)
copy(b, user)
return b
}
// decodeBalanceResp reads (status, realized amount) from a clob_deposit /
// clob_withdraw response (status[1] + amount[8]). A short response is malformed
// wire and is refused — the proxy never derives a settlement amount from garbage.
func decodeBalanceResp(resp []byte) (status uint8, amount uint64, err error) {
if len(resp) < BalanceRespSize {
return 0, 0, fmt.Errorf("dexvm custody: balance response too short: %d", len(resp))
}
return resp[0], binary.BigEndian.Uint64(resp[1:9]), nil
}
// executeDeposit runs the DEPOSIT rail for one import: atomically claim the
// imported value (consume-once) and relay a clob_deposit to credit exactly that
// value into the importer's available D-Chain balance. The credited amount is the
// import's total output value (the value locked by the atomic leg), so the
// D-Chain ledger gains precisely what C/X lost — conservation across the rail.
//
// The relay is the irreversible/non-deterministic leg; in production it runs once
// at the proposer's BuildBlock (carried as an idempotent ack). Here it composes
// the already-tested executeImport with the relay so a caller (BuildBlock plan,
// or the conservation test) gets the full funds-in path in one call.
func (vm *VM) executeDeposit(ctx context.Context, tx *txs.ImportTx, ar *atomicRequests) error {
// 1) Atomic import: consume the source UTXO(s), accumulate the remove. This is
// the SAME consume-once-guarded primitive the escrow path used; the only
// difference is what happens to the value next (credited to the D-Chain
// ledger, not recorded as proxy escrow).
if err := vm.executeImport(tx, ar); err != nil {
return err
}
// 2) Credit the D-Chain ledger with exactly the imported value, in the imported
// asset, for the importer. A deposit with no credited outputs (pure fee
// burn) credits nothing.
if len(tx.Outputs) == 0 {
return nil
}
owner := tx.Outputs[0].Owner
// The FULL 32-byte cross-chain asset id (native == ids.Empty) — keyed at full
// width on the D-Chain ledger so distinct imported assets never collide.
asset := tx.Outputs[0].Asset
var amount uint64
for _, o := range tx.Outputs {
// All outputs of a single deposit are the same (owner, asset) — sum the
// imported value into one ledger credit.
amount += o.Amount
}
// The ref is the import tx's own id — the cross-chain identity of THIS deposit,
// consumed exactly once on the atomic leg. Threading it into the clob_deposit
// frame makes a genuinely-distinct import distinct on the D-Chain seen: index
// (so a second genuine deposit credits separately, matching its own atomic
// import); a true retry of the same import (same id) dedups exactly once.
frame := encodeDepositFrame(userHandle(owner), asset, amount, tx.ID())
resp, err := vm.relay.Relay(ctx, ZAPMethodDeposit, frame)
if err != nil {
return fmt.Errorf("dexvm custody: deposit relay: %w", err)
}
status, credited, derr := decodeBalanceResp(resp)
if derr != nil {
return derr
}
// The D-Chain must credit exactly the imported value; a short credit would
// strand value (imported but not in the ledger). Refuse the divergence rather
// than silently lose funds.
if credited != amount {
return fmt.Errorf("dexvm custody: deposit credited %d != imported %d (status %d)", credited, amount, status)
}
return nil
}
// executeWithdraw runs the WITHDRAW rail for one account/asset: relay a
// clob_withdraw to debit the account's realized available D-Chain balance
// (returning the REALIZED amount the ledger released, clamped to availability),
// then atomically EXPORT exactly that realized amount back to the destination
// chain. The export value is the ledger's realized debit, so C/X gains precisely
// what the D-Chain ledger released — never more (no mint).
//
// owner is the account (its address); asset is the cross-chain asset id; want is
// the requested withdrawal (the ledger clamps it to available). destChain is
// where the value is exported (C-Chain). fillRef binds the export for audit AND is
// now the clob_withdraw idempotency ref the D-Chain folds into its seen: key, so
// it MUST be UNIQUE per genuine withdraw: two distinct withdraws sharing a fillRef
// would collide on the D-Chain seen: index and the second would replay the first
// realized amount against an already-exported balance (a proxy-rail double export).
// Every current caller passes a unique id (tests: ids.GenerateTestID; the future
// BuildBlock carry-result plan must derive a per-withdraw-unique ref, e.g. the
// settlement/UTXO id). The returned realized amount is what was exported (0 =>
// nothing available, no export
// built). The relay is the irreversible leg (proposer-once + carry the realized
// amount in production).
func (vm *VM) executeWithdraw(ctx context.Context, owner ids.ShortID, asset ids.ID, want uint64, destChain, fillRef ids.ID, txIndex uint32, createdAt int64, ar *atomicRequests) (uint64, error) {
// Consume-once on the settlement fillRef (R2), checked BEFORE the irreversible
// relay so a duplicate withdraw fails fast without re-debiting the D-Chain or
// double-exporting. Mirrors executeImport's IsConsumed/MarkConsumed on the
// import UTXO id, using the SAME state consumed-set. The MarkConsumed is deferred
// to after a successful relay with a positive realized amount (below), so a relay
// FAILURE or a zero-realized withdraw never burns the ref — a legitimate retry of
// a failed withdraw still works, while a genuine duplicate is rejected here.
if consumed, cerr := vm.state.IsConsumed(fillRef); cerr != nil {
return 0, fmt.Errorf("dexvm custody: withdraw consumed check: %w", cerr)
} else if consumed {
return 0, fmt.Errorf("dexvm custody: withdraw: %w: %s", errFillRefAlreadyExported, fillRef)
}
// The ref is the settlement fillRef — the unique reference that already binds
// this withdraw's export for audit. Threading it into the clob_withdraw frame
// makes a genuinely-distinct withdraw distinct on the D-Chain seen: index, so a
// second genuine withdraw re-clamps to CURRENT available rather than replaying
// the first realized amount against an already-exported balance (the drain).
frame := encodeWithdrawFrame(userHandle(owner), asset, want, fillRef)
resp, err := vm.relay.Relay(ctx, ZAPMethodWithdraw, frame)
if err != nil {
return 0, fmt.Errorf("dexvm custody: withdraw relay: %w", err)
}
_, realized, derr := decodeBalanceResp(resp)
if derr != nil {
return 0, derr
}
if realized == 0 {
return 0, nil // nothing available to withdraw; no export leg
}
if realized > want {
// The ledger released MORE than requested — impossible for a clamped
// withdraw and a mint risk. Refuse rather than export the excess.
return 0, fmt.Errorf("dexvm custody: withdraw realized %d exceeds requested %d (mint risk)", realized, want)
}
// Commit the fillRef to the consume-once set NOW that a real export will follow
// (relay succeeded, realized>0). A second withdraw with this fillRef is refused
// at the IsConsumed gate above before it can double-export (R2).
if cerr := vm.state.MarkConsumed(fillRef); cerr != nil {
return 0, fmt.Errorf("dexvm custody: withdraw mark consumed: %w", cerr)
}
// Atomic export of EXACTLY the realized amount in the withdrawn asset, on the LP
// lane (RailLP) — so the precompile's ImportPositionCollect is the ONLY C-side path
// that can claim it (a swap-settlement consume of this object is rejected). This is
// the D side of the H1 rail bind for the LP collect/withdraw leg.
out := []txs.AtomicOutput{{Rail: txs.RailLP, Owner: owner, Asset: asset, Amount: realized}}
exportTx := txs.NewSettlementExportTx(owner, txIndex, destChain, out, fillRef, createdAt)
if err := vm.executeExport(exportTx, ar); err != nil {
return 0, fmt.Errorf("dexvm custody: withdraw export: %w", err)
}
return realized, nil
}
-488
View File
@@ -1,488 +0,0 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// custody_conservation_test.go proves the FULL CLOB custody rail conserves value
// across the C-Chain <-> D-Chain boundary for a NON-NATIVE asset: atomically
// DEPOSIT into the D-Chain (ImportTx + clob_deposit), TRADE inside the D-Chain
// (modeled by the ledger matcher moving balances), and WITHDRAW back out
// (clob_withdraw + ExportTx). The acceptance criterion is the same one the
// taker-only escrow model could not meet for the maker leg: C-Chain value OUT ==
// C-Chain value IN, to the unit, with the proxy minting NOTHING (import consumes
// once; export releases only realized ledger balance).
//
// REAL atomic.SharedMemory drives the import/export legs; a ledger-modeling fake
// matcher stands in for the D-Chain over the zapDialer seam, so clob_deposit
// credits and clob_withdraw debits a real in-test balance ledger — exactly what
// the dchain.VM does in consensus (pkg/dchain/conservation_test.go proves THAT
// side end-to-end against the real VM).
package dexvm
import (
"context"
"encoding/binary"
"errors"
"testing"
"github.com/luxfi/database/memdb"
"github.com/luxfi/database/prefixdb"
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/runtime"
"github.com/luxfi/vm"
"github.com/luxfi/vm/chains/atomic"
"github.com/luxfi/warp"
"github.com/luxfi/chains/dexvm/config"
"github.com/luxfi/chains/dexvm/txs"
)
// ledgerMatcher is a fake D-Chain over the zapConn seam that maintains a real
// per-(user,asset) available-balance ledger. clob_deposit credits it; clob_submit
// (a trade) moves balance between two accounts; clob_withdraw debits it (clamped
// to available) and reports the realized amount. It models EXACTLY the invariant
// the real d-chain enforces — sum of balances is conserved across deposit ->
// trade -> withdraw — so the proxy rail can be proven against a faithful stand-in.
type ledgerMatcher struct {
bal map[string]uint64 // key = user16 || asset32 (FULL injective id)
received [][]byte
}
func newLedgerMatcher() *ledgerMatcher { return &ledgerMatcher{bal: map[string]uint64{}} }
// ledgerKey keys the fake D-Chain balance ledger by user[16] || asset[32], the
// FULL injective cross-chain asset id (native == ids.Empty) — byte-faithful to the
// real d-chain balance:<user:8><asset:32> keyspace (the asset is keyed at full
// width so two assets sharing a leading prefix never collide).
func ledgerKey(user []byte, asset ids.ID) string {
return string(user) + string(asset[:])
}
func (m *ledgerMatcher) Call(_ context.Context, method string, payload []byte) ([]byte, error) {
m.received = append(m.received, append([]byte(nil), payload...))
switch method {
case ZAPMethodDeposit:
user := payload[0:16]
var asset ids.ID
copy(asset[:], payload[16:custodyAmountOff])
amount := binary.BigEndian.Uint64(payload[custodyAmountOff:custodyRefOff])
m.bal[ledgerKey(user, asset)] += amount
return balResp(0, amount), nil // credited exactly
case ZAPMethodWithdraw:
user := payload[0:16]
var asset ids.ID
copy(asset[:], payload[16:custodyAmountOff])
want := binary.BigEndian.Uint64(payload[custodyAmountOff:custodyRefOff])
k := ledgerKey(user, asset)
realized := want
if realized > m.bal[k] {
realized = m.bal[k] // clamp to available (no mint)
}
m.bal[k] -= realized
st := uint8(0)
if realized == 0 {
st = 2
}
return balResp(st, realized), nil
default:
// place/cancel/ensure/open acks.
ack := make([]byte, 9)
return ack, nil
}
}
func (m *ledgerMatcher) Close() error { return nil }
// trade moves `base` of `baseAsset` from seller->buyer and `quote` of `quoteAsset`
// from buyer->seller inside the ledger — what an accepted D-Chain block's
// settleFills does. Used by the test to model the cross between the deposit and
// withdraw legs (the proxy does not trade; the D-Chain does). buyer/seller are
// the 16-byte frame-form user identities (frameUser).
func (m *ledgerMatcher) trade(buyer, seller []byte, baseAsset, quoteAsset ids.ID, base, quote uint64) {
m.bal[ledgerKey(buyer, quoteAsset)] -= quote
m.bal[ledgerKey(seller, quoteAsset)] += quote
m.bal[ledgerKey(seller, baseAsset)] -= base
m.bal[ledgerKey(buyer, baseAsset)] += base
}
// frameUser returns the 16-byte (UserSize) frame-form user identity for an
// address — the exact bytes the deposit/withdraw frame carries (the proxy
// truncates a 20-byte address to the 16-byte user field; the D-Chain then folds
// THAT to its 8-byte handle). The test keys its ledger by this so it matches the
// matcher's view byte-for-byte.
func frameUser(addr ids.ShortID) []byte { return padUser(userHandle(addr)) }
func balResp(status uint8, amount uint64) []byte {
out := make([]byte, BalanceRespSize)
out[0] = status
binary.BigEndian.PutUint64(out[1:9], amount)
return out
}
// custodyHarness wires a proxy VM with REAL two-chain shared memory and the
// ledger matcher.
type custodyHarness struct {
vm *VM
cChainSM atomic.SharedMemory
proxyChain ids.ID
cChain ids.ID
ledger *ledgerMatcher
}
func newCustodyHarness(t *testing.T) *custodyHarness {
t.Helper()
logger := log.NewNoOpLogger()
baseDB := memdb.New()
memoryDB := prefixdb.New([]byte{0}, baseDB)
m := atomic.NewMemory(memoryDB)
proxyChain := ids.GenerateTestID()
cChain := ids.GenerateTestID()
proxySM := m.NewSharedMemory(proxyChain)
cChainSM := m.NewSharedMemory(cChain)
ledger := newLedgerMatcher()
prev := zapDialer
zapDialer = func(_ context.Context, _ string) (zapConn, error) { return ledger, nil }
t.Cleanup(func() { zapDialer = prev })
rt := &runtime.Runtime{ChainID: proxyChain, CChainID: cChain, NetworkID: 96369, Log: logger, SharedMemory: proxySM}
v := &VM{}
v.Config = config.DefaultConfig()
v.Config.DexZapEndpoint = "127.0.0.1:0"
if err := v.Initialize(context.Background(), vm.Init{
Runtime: rt, DB: prefixdb.New([]byte{1}, baseDB), ToEngine: make(chan vm.Message, 8),
Sender: warp.FakeSender{}, Log: logger,
}); err != nil {
t.Fatalf("init proxy vm: %v", err)
}
v.bootstrapped = true
return &custodyHarness{vm: v, cChainSM: cChainSM, proxyChain: proxyChain, cChain: cChain, ledger: ledger}
}
// fundCChain exports `amount` of `asset` owned by `owner` into shared memory on the
// SWAP rail (the proxy can claim it on an import), returning the source UTXO id.
func (h *custodyHarness) fundCChain(t *testing.T, owner ids.ShortID, asset ids.ID, amount uint64) ids.ID {
return h.fundCChainRail(t, txs.RailSwap, owner, asset, amount)
}
// fundCChainRail is fundCChain parametrized by the object's RAIL — the lane the C->D
// object travels (RailSwap for a swap intent, RailLP for an LP commit). The import
// binds the credited outputs' rail to this recorded rail, so the importing tx's
// outputs must carry the SAME rail.
func (h *custodyHarness) fundCChainRail(t *testing.T, rail txs.Rail, owner ids.ShortID, asset ids.ID, amount uint64) ids.ID {
t.Helper()
srcUTXOID := deriveUTXOID(ids.GenerateTestID(), 0)
val := encodeExportedOutput(txs.AtomicOutput{Rail: rail, Owner: owner, Asset: asset, Amount: amount})
if err := h.cChainSM.Apply(map[ids.ID]*atomic.Requests{
h.proxyChain: {PutRequests: []*atomic.Element{{Key: srcUTXOID[:], Value: val, Traits: [][]byte{owner[:]}}}},
}); err != nil {
t.Fatalf("fund C-Chain: %v", err)
}
return srcUTXOID
}
// exportedTo sums the value the proxy exported back to C-Chain for an owner.
func (h *custodyHarness) exportedTo(t *testing.T, owner ids.ShortID) uint64 {
t.Helper()
vals, _, _, err := h.cChainSM.Indexed(h.proxyChain, [][]byte{owner[:]}, nil, nil, 100)
if err != nil {
t.Fatalf("Indexed: %v", err)
}
var total uint64
for _, v := range vals {
if len(v) >= exportedOutputSize {
total += binary.BigEndian.Uint64(v[53:61])
}
}
return total
}
// TestCustodyRailFullCycleConserves is the headline proxy proof: two accounts
// DEPOSIT a non-native asset into the D-Chain via the atomic rail, the D-Chain
// matches them (modeled), and BOTH WITHDRAW — asserting C-Chain value out ==
// C-Chain value in (to the unit) and that the source UTXOs were consumed exactly
// once. This is the maker+taker conservation the prior taker-only model lacked.
func TestCustodyRailFullCycleConserves(t *testing.T) {
h := newCustodyHarness(t)
ctx := context.Background()
maker := ids.GenerateTestShortID()
taker := ids.GenerateTestShortID()
lux := ids.GenerateTestID() // base asset (LUX)
lusd := ids.GenerateTestID() // quote asset (LUSD)
// --- DEPOSIT: maker funds 100 LUX, taker funds 1000 LUSD, atomically. ---
makerUTXO := h.fundCChain(t, maker, lux, 100)
takerUTXO := h.fundCChain(t, taker, lusd, 1000)
arDep := newAtomicRequests()
makerImport := txs.NewImportTx(maker, 0, h.cChain,
[]txs.AtomicInput{{UTXOID: makerUTXO, Asset: lux, Amount: 100}},
[]txs.AtomicOutput{{Owner: maker, Asset: lux, Amount: 100}})
takerImport := txs.NewImportTx(taker, 0, h.cChain,
[]txs.AtomicInput{{UTXOID: takerUTXO, Asset: lusd, Amount: 1000}},
[]txs.AtomicOutput{{Owner: taker, Asset: lusd, Amount: 1000}})
if err := h.vm.executeDeposit(ctx, makerImport, arDep); err != nil {
t.Fatalf("maker deposit: %v", err)
}
if err := h.vm.executeDeposit(ctx, takerImport, arDep); err != nil {
t.Fatalf("taker deposit: %v", err)
}
// Commit the atomic deposit legs (consumes the source UTXOs).
if err := h.vm.commitAtomic(arDep, nil); err != nil {
t.Fatalf("commit deposits: %v", err)
}
// The D-Chain ledger now holds the deposited value.
if got := h.ledger.bal[ledgerKey(frameUser(maker), lux)]; got != 100 {
t.Fatalf("maker LUX in ledger = %d, want 100", got)
}
if got := h.ledger.bal[ledgerKey(frameUser(taker), lusd)]; got != 1000 {
t.Fatalf("taker LUSD in ledger = %d, want 1000", got)
}
// --- TRADE inside the D-Chain: taker buys 10 LUX @ 5 = 50 LUSD. ---
// (The proxy does not trade; the D-Chain matcher does. We model the accepted
// block's settleFills moving balances.)
h.ledger.trade(frameUser(taker), frameUser(maker), lux, lusd, 10, 50)
// Ledger after trade: maker 90 LUX + 50 LUSD ; taker 10 LUX + 950 LUSD.
assertLedger(t, h, maker, lux, 90)
assertLedger(t, h, maker, lusd, 50)
assertLedger(t, h, taker, lux, 10)
assertLedger(t, h, taker, lusd, 950)
// OWNERSHIP (proxy-boundary mirror of the dchain invariant): every account
// holding ledger value must be an EXPLICIT PARTY — the depositor (deposit), the
// maker or taker (trade), or the withdrawer (withdraw). No THIRD account ever
// holds value. This is the proxy-rail analog of pkg/dchain's ownership harness:
// conservation alone (sum unchanged) would not catch value mis-credited to a
// non-party (a colliding handle), so we assert the ledger's account set is
// exactly the explicit parties. Here both parties are maker+taker.
assertOnlyParties(t, h.ledger, frameUser(maker), frameUser(taker))
// --- WITHDRAW: every account pulls its full realized balance, atomically. ---
arW := newAtomicRequests()
withdrawAll := func(owner ids.ShortID, asset ids.ID, want uint64, idx uint32) uint64 {
r, err := h.vm.executeWithdraw(ctx, owner, asset, want, h.cChain, ids.GenerateTestID(), idx, 1, arW)
if err != nil {
t.Fatalf("withdraw owner=%x asset=%x: %v", owner[:4], asset[:4], err)
}
return r
}
var totalOut uint64
totalOut += withdrawAll(maker, lux, 90, 0)
totalOut += withdrawAll(maker, lusd, 50, 1)
totalOut += withdrawAll(taker, lux, 10, 2)
totalOut += withdrawAll(taker, lusd, 950, 3)
if err := h.vm.commitAtomic(arW, nil); err != nil {
t.Fatalf("commit withdrawals: %v", err)
}
// --- CONSERVATION across the rail ---
const totalIn = 100 + 1000
if totalOut != totalIn {
t.Fatalf("CONSERVATION VIOLATED: withdrawn %d != deposited %d", totalOut, totalIn)
}
// C-Chain can now claim exactly the exported proceeds for each account.
if got := h.exportedTo(t, maker); got != 90+50 {
t.Fatalf("maker C-Chain proceeds = %d, want 140", got)
}
if got := h.exportedTo(t, taker); got != 10+950 {
t.Fatalf("taker C-Chain proceeds = %d, want 960", got)
}
// The ledger is empty — every deposited unit was either traded or withdrawn.
for k, v := range h.ledger.bal {
if v != 0 {
t.Errorf("ledger not drained: key %x has %d", []byte(k), v)
}
}
// Source UTXOs consumed exactly once (double-spend guard held).
for _, u := range []ids.ID{makerUTXO, takerUTXO} {
if c, _ := h.vm.state.IsConsumed(u); !c {
t.Errorf("source UTXO %x not consumed", u[:4])
}
}
}
// TestCustodyWithdrawCannotExceedRealized proves a withdraw exports only the
// ledger's realized (clamped) balance: asking for more than is available exports
// only what is there — the proxy never mints on the export leg.
func TestCustodyWithdrawCannotExceedRealized(t *testing.T) {
h := newCustodyHarness(t)
ctx := context.Background()
owner := ids.GenerateTestShortID()
asset := ids.GenerateTestID()
// Fund + deposit 40.
u := h.fundCChain(t, owner, asset, 40)
ar := newAtomicRequests()
imp := txs.NewImportTx(owner, 0, h.cChain,
[]txs.AtomicInput{{UTXOID: u, Asset: asset, Amount: 40}},
[]txs.AtomicOutput{{Owner: owner, Asset: asset, Amount: 40}})
if err := h.vm.executeDeposit(ctx, imp, ar); err != nil {
t.Fatalf("deposit: %v", err)
}
if err := h.vm.commitAtomic(ar, nil); err != nil {
t.Fatalf("commit: %v", err)
}
// Try to withdraw 100; only 40 is realized.
arW := newAtomicRequests()
realized, err := h.vm.executeWithdraw(ctx, owner, asset, 100, h.cChain, ids.GenerateTestID(), 0, 1, arW)
if err != nil {
t.Fatalf("withdraw: %v", err)
}
if realized != 40 {
t.Fatalf("over-withdraw realized %d, want clamped 40", realized)
}
if err := h.vm.commitAtomic(arW, nil); err != nil {
t.Fatalf("commit withdraw: %v", err)
}
if got := h.exportedTo(t, owner); got != 40 {
t.Fatalf("exported %d, want 40 (no mint)", got)
}
}
// TestCustodyDepositCreditsExactImport proves the deposit rail credits the
// D-Chain ledger with EXACTLY the imported value (no skim, no inflation), and a
// short/over credit from a lying matcher is refused.
func TestCustodyDepositCreditsExactImport(t *testing.T) {
h := newCustodyHarness(t)
ctx := context.Background()
owner := ids.GenerateTestShortID()
asset := ids.GenerateTestID()
u := h.fundCChain(t, owner, asset, 777)
ar := newAtomicRequests()
imp := txs.NewImportTx(owner, 0, h.cChain,
[]txs.AtomicInput{{UTXOID: u, Asset: asset, Amount: 777}},
[]txs.AtomicOutput{{Owner: owner, Asset: asset, Amount: 777}})
if err := h.vm.executeDeposit(ctx, imp, ar); err != nil {
t.Fatalf("deposit: %v", err)
}
if got := h.ledger.bal[ledgerKey(frameUser(owner), asset)]; got != 777 {
t.Fatalf("ledger credited %d, want exactly 777", got)
}
}
func assertLedger(t *testing.T, h *custodyHarness, owner ids.ShortID, asset ids.ID, want uint64) {
t.Helper()
if got := h.ledger.bal[ledgerKey(frameUser(owner), asset)]; got != want {
t.Fatalf("ledger owner=%x asset=%x = %d, want %d", owner[:4], asset[:4], got, want)
}
}
// assertOnlyParties is the proxy-boundary OWNERSHIP gate: every account holding a
// non-zero ledger balance must be one of the supplied EXPLICIT PARTIES (the
// 16-byte frame-form user identities). A non-zero balance for any other account is
// value credited to a NON-party — the theft a conservation sum cannot see. The
// ledger key is user[16]||asset[32], so the account is the leading 16 bytes.
func assertOnlyParties(t *testing.T, m *ledgerMatcher, parties ...[]byte) {
t.Helper()
allowed := map[string]struct{}{}
for _, p := range parties {
allowed[string(p)] = struct{}{}
}
for k, v := range m.bal {
if v == 0 {
continue
}
if len(k) < 16 {
t.Fatalf("ledger key too short: %x", []byte(k))
}
user := k[:16]
if _, ok := allowed[user]; !ok {
t.Fatalf("OWNERSHIP VIOLATED: account %x holds %d in the ledger but is NOT an explicit party (deposit/trade/withdraw) — value credited to a non-party", []byte(user), v)
}
}
}
// TestCustodyWithdraw_DuplicateFillRefRejected pins R2: the proxy WITHDRAW rail
// consumes the settlement fillRef exactly once, so a duplicate fillRef cannot
// double-export (a drain) even if the D-Chain ledger still holds a withdrawable
// balance. This is the proxy-local second line of defense, independent of the
// D-Chain seen: clamp, and correct regardless of how the proxy BuildBlock rail is
// later wired. A DIFFERENT fillRef still works (consume-once is per-ref, not a
// global block).
func TestCustodyWithdraw_DuplicateFillRefRejected(t *testing.T) {
h := newCustodyHarness(t)
ctx := context.Background()
owner := ids.GenerateTestShortID()
asset := ids.GenerateTestID()
// Fund + deposit 200, so the ledger retains a balance AFTER the first withdraw —
// without consume-once, a duplicate-fillRef second withdraw WOULD double-export.
u := h.fundCChain(t, owner, asset, 200)
arD := newAtomicRequests()
imp := txs.NewImportTx(owner, 0, h.cChain,
[]txs.AtomicInput{{UTXOID: u, Asset: asset, Amount: 200}},
[]txs.AtomicOutput{{Owner: owner, Asset: asset, Amount: 200}})
if err := h.vm.executeDeposit(ctx, imp, arD); err != nil {
t.Fatalf("deposit: %v", err)
}
if err := h.vm.commitAtomic(arD, nil); err != nil {
t.Fatalf("commit deposit: %v", err)
}
assertLedger(t, h, owner, asset, 200)
fillRef := ids.GenerateTestID()
// Withdraw #1 with fillRef: realized 100, exports 100, consumes the fillRef.
ar1 := newAtomicRequests()
r1, err := h.vm.executeWithdraw(ctx, owner, asset, 100, h.cChain, fillRef, 0, 1, ar1)
if err != nil {
t.Fatalf("withdraw #1: %v", err)
}
if r1 != 100 {
t.Fatalf("withdraw #1 realized = %d, want 100", r1)
}
if err := h.vm.commitAtomic(ar1, nil); err != nil {
t.Fatalf("commit withdraw #1: %v", err)
}
assertLedger(t, h, owner, asset, 100) // 100 still withdrawable
if got := h.exportedTo(t, owner); got != 100 {
t.Fatalf("exported after #1 = %d, want 100", got)
}
// Withdraw #2 with the SAME fillRef: rejected by consume-once BEFORE the relay,
// so it neither debits the ledger again nor exports a second time.
ar2 := newAtomicRequests()
r2, err := h.vm.executeWithdraw(ctx, owner, asset, 100, h.cChain, fillRef, 1, 1, ar2)
if !errors.Is(err, errFillRefAlreadyExported) {
t.Fatalf("duplicate-fillRef withdraw err = %v, want errFillRefAlreadyExported", err)
}
if r2 != 0 {
t.Fatalf("duplicate-fillRef withdraw realized = %d, want 0", r2)
}
if !ar2.empty() {
t.Fatal("duplicate-fillRef withdraw accumulated an export — double-export not prevented")
}
if err := h.vm.commitAtomic(ar2, nil); err != nil {
t.Fatalf("commit (empty) withdraw #2: %v", err)
}
// No double export, and the ledger was NOT re-debited (the duplicate never relayed).
assertLedger(t, h, owner, asset, 100)
if got := h.exportedTo(t, owner); got != 100 {
t.Fatalf("exported after rejected duplicate = %d, want 100 (no double-export)", got)
}
// A DISTINCT fillRef still works: consume-once is per-ref, not a global block.
ar3 := newAtomicRequests()
r3, err := h.vm.executeWithdraw(ctx, owner, asset, 100, h.cChain, ids.GenerateTestID(), 2, 1, ar3)
if err != nil {
t.Fatalf("withdraw #3 (distinct fillRef): %v", err)
}
if r3 != 100 {
t.Fatalf("withdraw #3 realized = %d, want 100", r3)
}
if err := h.vm.commitAtomic(ar3, nil); err != nil {
t.Fatalf("commit withdraw #3: %v", err)
}
assertLedger(t, h, owner, asset, 0)
if got := h.exportedTo(t, owner); got != 200 {
t.Fatalf("exported after #3 = %d, want 200 (100 + 100, distinct refs)", got)
}
}
-366
View File
@@ -1,366 +0,0 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dexvm
import (
"context"
"errors"
"testing"
"github.com/luxfi/ids"
"github.com/luxfi/vm/chains/atomic"
"github.com/luxfi/chains/dexvm/txs"
)
// custody_import_asset_bind_test.go is the PERMANENT REGRESSION for the native-
// aliasing custody bug (CRITICAL-2).
//
// THE BUG (red-team PoC, confirmed): executeImport consumed source UTXOs by ID
// ONLY and never read back the recorded asset of the value it claimed; executeDeposit
// then credited the ledger with the asset the IMPORTING TX DECLARED (Outputs[0].Asset).
// So an attacker could fund a worthless-token export, then import declaring
// Output.Asset = ids.Empty (native LUX) — and the native ledger was credited,
// backed by a bogus token. Native value materialized from nothing.
//
// THE FIX: bind the credited asset to the asset the chain ACTUALLY holds for the
// consumed UTXO. executeImport reads the recorded value (owner|asset|amount) back
// from shared memory (the export side wrote it via encodeExportedOutput) and
// rejects any tx whose declared input asset/amount disagrees, whose consumed UTXOs
// span multiple assets, or whose credited output names a different asset.
// ImportTx.Verify pins the structural half (output.Asset == input.Asset). Composed,
// the credit is provably the consumed asset.
//
// These tests use the REAL two-chain shared memory (custodyHarness), exactly the
// rail the conservation tests exercise.
// importTxAliasing builds an ImportTx that consumes utxoID but DECLARES inAsset on
// the input and outAsset on the credited output — the attacker's free choice of
// what they claim to import vs. what they actually hold.
func importTxAliasing(from ids.ShortID, sourceChain, utxoID, inAsset, outAsset ids.ID, amount uint64) *txs.ImportTx {
return txs.NewImportTx(from, 0, sourceChain,
[]txs.AtomicInput{{UTXOID: utxoID, Asset: inAsset, Amount: amount}},
[]txs.AtomicOutput{{Owner: from, Asset: outAsset, Amount: amount}})
}
// TestCustody_ImportRejectsNativeAliasing_DeclaredInputLie is PoC variant A: the
// attacker lies on the INPUT asset (declaring native, ids.Empty) to make the tx
// internally consistent (input == output == native), but the consumed UTXO is
// actually a bogus token. The authoritative shared-memory bind in executeImport
// catches it: declared input asset (native) != recorded UTXO asset (bogus token).
//
// FAIL-on-old -> PASS-on-fix: pre-fix executeImport never read the recorded asset,
// so the native ledger was credited from a bogus-token UTXO. This asserts REJECTION
// and a ZERO native credit.
func TestCustody_ImportRejectsNativeAliasing_DeclaredInputLie(t *testing.T) {
h := newCustodyHarness(t)
ctx := context.Background()
attacker := ids.GenerateTestShortID()
bogusToken := ids.GenerateTestID() // a worthless token the attacker controls
native := ids.Empty // the asset they want minted
// Fund a REAL UTXO recorded as the bogus token.
utxo := h.fundCChain(t, attacker, bogusToken, 5000)
// Import it while DECLARING native on both input and output (passes the
// structural Verify; only the recorded-asset bind can catch this).
tx := importTxAliasing(attacker, h.cChain, utxo, native, native, 5000)
ar := newAtomicRequests()
err := h.vm.executeDeposit(ctx, tx, ar)
if !errors.Is(err, errImportAssetMismatch) {
t.Fatalf("aliasing import (declared-input-lie) err = %v, want errImportAssetMismatch (CRITICAL-2 regressed: native minted from a bogus token)", err)
}
// The native ledger must NOT have been credited.
if got := h.ledger.bal[ledgerKey(frameUser(attacker), native)]; got != 0 {
t.Fatalf("native ledger credited %d from a bogus-token UTXO, want 0 (no mint)", got)
}
// The bogus token must NOT have been credited either (the import was rejected
// before any credit).
if got := h.ledger.bal[ledgerKey(frameUser(attacker), bogusToken)]; got != 0 {
t.Fatalf("bogus-token ledger credited %d on a rejected import, want 0", got)
}
}
// TestCustody_ImportRejectsNativeAliasing_OutputLie is PoC variant B: the attacker
// declares the INPUT truthfully (the bogus token) but sets the credited OUTPUT to
// native. ImportTx.Verify's structural bind (output.Asset == input.Asset) rejects
// it before any state is touched.
func TestCustody_ImportRejectsNativeAliasing_OutputLie(t *testing.T) {
h := newCustodyHarness(t)
ctx := context.Background()
attacker := ids.GenerateTestShortID()
bogusToken := ids.GenerateTestID()
native := ids.Empty
utxo := h.fundCChain(t, attacker, bogusToken, 5000)
// Truthful input (bogus token), but output re-denominated to native.
tx := importTxAliasing(attacker, h.cChain, utxo, bogusToken, native, 5000)
ar := newAtomicRequests()
err := h.vm.executeDeposit(ctx, tx, ar)
if err == nil {
t.Fatalf("aliasing import (output-lie) was accepted, want rejection (CRITICAL-2)")
}
if got := h.ledger.bal[ledgerKey(frameUser(attacker), native)]; got != 0 {
t.Fatalf("native ledger credited %d on an output-lie import, want 0 (no mint)", got)
}
if got := h.ledger.bal[ledgerKey(frameUser(attacker), bogusToken)]; got != 0 {
t.Fatalf("bogus-token ledger credited %d on a rejected import, want 0", got)
}
}
// TestCustody_ImportRejectsAmountInflation pins the amount axis of the same bind:
// an import that DECLARES more than the consumed UTXO actually holds is rejected,
// so the proxy never credits an inflated amount.
func TestCustody_ImportRejectsAmountInflation(t *testing.T) {
h := newCustodyHarness(t)
ctx := context.Background()
attacker := ids.GenerateTestShortID()
token := ids.GenerateTestID()
utxo := h.fundCChain(t, attacker, token, 100) // chain holds 100
// Declare 1_000_000 of the SAME asset (truthful asset, inflated amount).
tx := importTxAliasing(attacker, h.cChain, utxo, token, token, 1_000_000)
ar := newAtomicRequests()
err := h.vm.executeDeposit(ctx, tx, ar)
if !errors.Is(err, errImportAmountMismatch) {
t.Fatalf("inflated-amount import err = %v, want errImportAmountMismatch", err)
}
if got := h.ledger.bal[ledgerKey(frameUser(attacker), token)]; got != 0 {
t.Fatalf("ledger credited %d on a rejected inflated import, want 0", got)
}
}
// TestCustody_ImportHonestAssetCredits is the POSITIVE control: an HONEST import
// (declared input asset/amount == the recorded UTXO, output == that same asset) is
// ACCEPTED and credits the ledger in the CONSUMED asset — never native. This proves
// the bind rejects only mismatches, not legitimate non-native deposits.
func TestCustody_ImportHonestAssetCredits(t *testing.T) {
h := newCustodyHarness(t)
ctx := context.Background()
owner := ids.GenerateTestShortID()
token := ids.GenerateTestID()
native := ids.Empty
utxo := h.fundCChain(t, owner, token, 777)
tx := importTxAliasing(owner, h.cChain, utxo, token, token, 777)
ar := newAtomicRequests()
if err := h.vm.executeDeposit(ctx, tx, ar); err != nil {
t.Fatalf("honest non-native import rejected: %v", err)
}
// Credited in the CONSUMED token, for exactly the imported amount.
if got := h.ledger.bal[ledgerKey(frameUser(owner), token)]; got != 777 {
t.Fatalf("token ledger credited %d, want 777", got)
}
// And NOT in native (no aliasing).
if got := h.ledger.bal[ledgerKey(frameUser(owner), native)]; got != 0 {
t.Fatalf("native ledger credited %d on a token import, want 0", got)
}
}
// importTxOwner builds an ImportTx that consumes utxoID (declaring asset/amount)
// but credits the value to creditOwner — which may DIFFER from the consumed UTXO's
// recorded owner. The free choice of credited owner is the attacker's lever the
// owner bind must close.
func importTxOwner(from ids.ShortID, sourceChain, utxoID, asset ids.ID, amount uint64, creditOwner ids.ShortID) *txs.ImportTx {
return txs.NewImportTx(from, 0, sourceChain,
[]txs.AtomicInput{{UTXOID: utxoID, Asset: asset, Amount: amount}},
[]txs.AtomicOutput{{Owner: creditOwner, Asset: asset, Amount: amount}})
}
// TestImportDeclaredNativeConsumedBogusRejects: an import that DECLARES the native
// asset on its input but consumes a UTXO recorded as a bogus token is rejected by
// the authoritative shared-memory asset bind (declared native != recorded token),
// and credits NOTHING — the headline native-aliasing theft, refused.
func TestImportDeclaredNativeConsumedBogusRejects(t *testing.T) {
h := newCustodyHarness(t)
ctx := context.Background()
attacker := ids.GenerateTestShortID()
bogusToken := ids.GenerateTestID()
native := ids.Empty
utxo := h.fundCChain(t, attacker, bogusToken, 5000) // chain records a bogus token
tx := importTxAliasing(attacker, h.cChain, utxo, native, native, 5000)
ar := newAtomicRequests()
if err := h.vm.executeDeposit(ctx, tx, ar); !errors.Is(err, errImportAssetMismatch) {
t.Fatalf("declared-native/consumed-bogus import err = %v, want errImportAssetMismatch", err)
}
if got := h.ledger.bal[ledgerKey(frameUser(attacker), native)]; got != 0 {
t.Fatalf("native credited %d from a bogus-token UTXO, want 0", got)
}
}
// TestImportDeclaredAssetMismatchRejects: declaring asset X while the consumed UTXO
// holds asset Y is rejected — the credited asset is bound to what the chain ACTUALLY
// holds, never what the tx declares.
func TestImportDeclaredAssetMismatchRejects(t *testing.T) {
h := newCustodyHarness(t)
ctx := context.Background()
owner := ids.GenerateTestShortID()
heldAsset := ids.GenerateTestID()
declaredAsset := ids.GenerateTestID() // a different asset
utxo := h.fundCChain(t, owner, heldAsset, 1234)
tx := importTxAliasing(owner, h.cChain, utxo, declaredAsset, declaredAsset, 1234)
ar := newAtomicRequests()
if err := h.vm.executeDeposit(ctx, tx, ar); !errors.Is(err, errImportAssetMismatch) {
t.Fatalf("declared-asset-mismatch import err = %v, want errImportAssetMismatch", err)
}
if got := h.ledger.bal[ledgerKey(frameUser(owner), declaredAsset)]; got != 0 {
t.Fatalf("declared asset credited %d on a mismatched import, want 0", got)
}
if got := h.ledger.bal[ledgerKey(frameUser(owner), heldAsset)]; got != 0 {
t.Fatalf("held asset credited %d on a rejected import, want 0", got)
}
}
// TestImportAmountMismatchRejects: declaring more (or less) than the consumed UTXO
// records is rejected — the amount axis of the authoritative bind.
func TestImportAmountMismatchRejects(t *testing.T) {
h := newCustodyHarness(t)
ctx := context.Background()
owner := ids.GenerateTestShortID()
token := ids.GenerateTestID()
utxo := h.fundCChain(t, owner, token, 100) // chain holds 100
tx := importTxAliasing(owner, h.cChain, utxo, token, token, 1_000_000)
ar := newAtomicRequests()
if err := h.vm.executeDeposit(ctx, tx, ar); !errors.Is(err, errImportAmountMismatch) {
t.Fatalf("amount-mismatch import err = %v, want errImportAmountMismatch", err)
}
if got := h.ledger.bal[ledgerKey(frameUser(owner), token)]; got != 0 {
t.Fatalf("ledger credited %d on a rejected amount-mismatch import, want 0", got)
}
}
// TestImportWrongOwnerRejects is the OWNER axis of the bind (the cross-chain analog
// of the settlement-identity collision): an attacker consumes a UTXO whose recorded
// owner is the VICTIM, but credits the value to the attacker's OWN account. The
// owner bind rejects it (recorded victim owner != credited attacker owner), so no
// account is credited — a victim's exported value cannot be stolen on import.
//
// FAIL-on-old -> PASS-on-fix: before the owner bind, executeImport read only the
// recorded asset/amount and never the recorded owner, so the attacker's freely
// chosen credit owner was honored — stealing the victim's exported UTXO.
func TestImportWrongOwnerRejects(t *testing.T) {
h := newCustodyHarness(t)
ctx := context.Background()
victim := ids.GenerateTestShortID()
attacker := ids.GenerateTestShortID()
token := ids.GenerateTestID()
// The chain records the UTXO as owned by the VICTIM.
utxo := h.fundCChain(t, victim, token, 9000)
// The attacker imports it, crediting their OWN account.
tx := importTxOwner(attacker, h.cChain, utxo, token, 9000, attacker)
ar := newAtomicRequests()
if err := h.vm.executeDeposit(ctx, tx, ar); !errors.Is(err, errImportWrongOwner) {
t.Fatalf("wrong-owner import err = %v, want errImportWrongOwner (THEFT: attacker credited a victim's exported UTXO)", err)
}
// Neither account is credited (the import was rejected before any credit).
if got := h.ledger.bal[ledgerKey(frameUser(attacker), token)]; got != 0 {
t.Fatalf("attacker credited %d from the victim's UTXO, want 0", got)
}
if got := h.ledger.bal[ledgerKey(frameUser(victim), token)]; got != 0 {
t.Fatalf("victim credited %d on a rejected import, want 0", got)
}
// POSITIVE control: the SAME UTXO imported by/for the rightful victim owner is
// ACCEPTED (the bind rejects only owner mismatches, not legitimate claims).
tx2 := importTxOwner(victim, h.cChain, utxo, token, 9000, victim)
ar2 := newAtomicRequests()
if err := h.vm.executeDeposit(ctx, tx2, ar2); err != nil {
t.Fatalf("rightful-owner import rejected: %v", err)
}
if got := h.ledger.bal[ledgerKey(frameUser(victim), token)]; got != 9000 {
t.Fatalf("victim credited %d on a rightful import, want 9000", got)
}
}
// TestImportSameTxIDDifferentOutputIndexDistinct proves the UTXO id binds the
// OUTPUT INDEX: two outputs of the SAME source tx (same txID, indices 0 and 1) are
// DISTINCT UTXOs — each consumable exactly once, neither aliasing the other. (If the
// id ignored the index, importing index 0 would also consume index 1.)
func TestImportSameTxIDDifferentOutputIndexDistinct(t *testing.T) {
h := newCustodyHarness(t)
owner := ids.GenerateTestShortID()
token := ids.GenerateTestID()
srcTxID := ids.GenerateTestID()
// Two UTXOs from the same source tx, distinguished only by output index.
utxo0 := deriveUTXOID(srcTxID, 0)
utxo1 := deriveUTXOID(srcTxID, 1)
if utxo0 == utxo1 {
t.Fatal("deriveUTXOID ignores the output index — same-tx outputs collide")
}
// Seed both into shared memory with distinct amounts.
for _, u := range []struct {
id ids.ID
amt uint64
}{{utxo0, 100}, {utxo1, 200}} {
val := encodeExportedOutput(txs.AtomicOutput{Owner: owner, Asset: token, Amount: u.amt})
if err := h.cChainSM.Apply(map[ids.ID]*atomic.Requests{
h.proxyChain: {PutRequests: []*atomic.Element{{Key: u.id[:], Value: val, Traits: [][]byte{owner[:]}}}},
}); err != nil {
t.Fatalf("seed UTXO: %v", err)
}
}
// Import output 0 (amount 100). Output 1 stays unconsumed and independently
// claimable.
ar := newAtomicRequests()
if err := h.vm.executeImport(importTxOwner(owner, h.cChain, utxo0, token, 100, owner), ar); err != nil {
t.Fatalf("import output 0: %v", err)
}
// Output 1 is NOT consumed by importing output 0.
if consumed, _ := h.vm.state.IsConsumed(utxo1); consumed {
t.Fatal("importing output 0 also consumed output 1 (output index not bound into the UTXO id)")
}
// And output 1 imports cleanly on its own (amount 200).
ar2 := newAtomicRequests()
if err := h.vm.executeImport(importTxOwner(owner, h.cChain, utxo1, token, 200, owner), ar2); err != nil {
t.Fatalf("import output 1: %v", err)
}
}
// TestImportReplaySameUTXORejects proves the same UTXO cannot be consumed twice: a
// second import of an already-consumed UTXO is rejected (errUTXOAlreadyImported), so
// the proxy never mints by re-importing already-claimed value.
func TestImportReplaySameUTXORejects(t *testing.T) {
h := newCustodyHarness(t)
owner := ids.GenerateTestShortID()
token := ids.GenerateTestID()
utxo := h.fundCChain(t, owner, token, 500)
// First import consumes the UTXO.
ar := newAtomicRequests()
if err := h.vm.executeImport(importTxOwner(owner, h.cChain, utxo, token, 500, owner), ar); err != nil {
t.Fatalf("first import: %v", err)
}
// Replaying the SAME UTXO is rejected.
ar2 := newAtomicRequests()
if err := h.vm.executeImport(importTxOwner(owner, h.cChain, utxo, token, 500, owner), ar2); !errors.Is(err, errUTXOAlreadyImported) {
t.Fatalf("replay of consumed UTXO err = %v, want errUTXOAlreadyImported", err)
}
}
-426
View File
@@ -1,426 +0,0 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dexvm
import (
"context"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"fmt"
"time"
"github.com/luxfi/consensus/core/choices"
"github.com/luxfi/consensus/engine/dag/vertex"
"github.com/luxfi/ids"
"github.com/luxfi/chains/dexvm/txs"
)
var _ vertex.DAGVM = (*ChainVM)(nil)
// OrderKey is the conflict key for the proxy: (poolId, side, orderID) for relay
// envelopes, or the consumed source-UTXO id for imports. Two vertices conflict
// iff their OrderKey sets intersect — relays/cancels touching the same resting
// order, or imports claiming the same exported UTXO. The proxy does NOT match,
// so this is a transport-level conflict key, not a matching one.
type OrderKey struct {
// Market is the 32-byte poolId (hex) for order relays, or "utxo" for the
// atomic-import double-spend key.
Market string
Side uint8
OrderID ids.ID
}
// DexVertex represents a DAG vertex in the DEX proxy chain.
type DexVertex struct {
id ids.ID
bytes []byte
height uint64
epoch uint32
// timestamp is the proposer-chosen block time, carried in the serialized
// vertex bytes and committed by computeID. Verify MUST use this value (NOT
// the local wall clock) so every validator processing the same vertex feeds
// the IDENTICAL time into deriveBlockHash -> receipt keys + computeStateRoot,
// yielding the same StateRoot. This mirrors the linear Block.timestamp path
// (block.go); injecting clock.Time() here forked StateRoot across validators.
timestamp time.Time
parents []ids.ID
txIDs []ids.ID
status choices.Status
rawTxs [][]byte
keys []OrderKey
// carriedFills are the proposer's confirmed d-chain fills, obtained once at
// BuildVertex and serialized into the vertex bytes; every validator settles
// from them without relaying (RED finding #9). fillSig is the reserved
// trustless-path attestation (empty today). Carrying these CHANGES THE VERTEX
// WIRE FORMAT — a network-upgrade-gated, lockstep validator change.
carriedFills []carriedFill
fillSig []byte
result *BlockResult
vm *ChainVM
}
func (v *DexVertex) ID() ids.ID { return v.id }
func (v *DexVertex) Bytes() []byte { return v.bytes }
func (v *DexVertex) Height() uint64 { return v.height }
func (v *DexVertex) Epoch() uint32 { return v.epoch }
func (v *DexVertex) Timestamp() time.Time { return v.timestamp }
func (v *DexVertex) Parents() []ids.ID { return v.parents }
func (v *DexVertex) Txs() []ids.ID { return v.txIDs }
func (v *DexVertex) Status() choices.Status { return v.status }
func (v *DexVertex) Verify(ctx context.Context) error {
if v.vm == nil || v.vm.inner == nil {
return errVMNotInitialized
}
// Use the proposer-carried timestamp, NOT v.vm.inner.clock.Time(): the wall
// clock differs per validator and flows through deriveBlockHash into receipt
// keys + computeStateRoot, which would fork the StateRoot for the same
// vertex. The carried time is consensus-agreed (committed by computeID).
result, err := v.vm.inner.ProcessBlock(ctx, v.height, v.timestamp, v.rawTxs)
if err != nil {
return err
}
// Attach the carried fills (RED #9): settleCarried at accept settles purely
// from these + the deterministic relay plan. Verify performs NO d-chain I/O on
// any node — the proposer relayed once at BuildVertex.
result.carriedFills = v.carriedFills
result.fillSig = v.fillSig
v.result = result
return nil
}
func (v *DexVertex) Accept(ctx context.Context) error {
v.status = choices.Accepted
v.vm.lock.Lock()
defer v.vm.lock.Unlock()
v.vm.lastAcceptedID = v.id
v.vm.lastAcceptedHeight = v.height
v.vm.blocks[v.id] = &Block{
vm: v.vm,
id: v.id,
parentID: v.parents[0],
height: v.height,
timestamp: v.timestamp,
txs: v.rawTxs,
status: StatusAccepted,
}
// Run the deferred relay plan (the irreversible d-chain leg) and commit the
// proxy's state batch ATOMICALLY with the cross-chain shared-memory operations
// (the settlement leg). This is the single commit point (the platformvm
// acceptor.go pattern): the relay never fires during Verify, so a Rejected
// vertex never strands a d-chain match; a failed atomic apply leaves NO
// committed state, so a d-side fill cannot strand without its C-side settle.
return v.vm.inner.acceptBlock(ctx, v.result)
}
func (v *DexVertex) Reject(ctx context.Context) error {
v.status = choices.Rejected
if v.vm.inner.db != nil {
v.vm.inner.db.Abort()
}
return nil
}
// conflictKeySet returns the set of OrderKeys for conflict detection.
func (v *DexVertex) conflictKeySet() map[OrderKey]struct{} {
s := make(map[OrderKey]struct{}, len(v.keys))
for _, k := range v.keys {
s[k] = struct{}{}
}
return s
}
// Conflicts returns true if this vertex and other share any (symbol, side, orderID) tuple.
func (v *DexVertex) Conflicts(other *DexVertex) bool {
ours := v.conflictKeySet()
for _, k := range other.keys {
if _, ok := ours[k]; ok {
return true
}
}
return false
}
// ConflictsVertex performs the same check against the vertex.Vertex interface.
func (v *DexVertex) ConflictsVertex(other vertex.Vertex) bool {
ov, ok := other.(*DexVertex)
if !ok {
return false
}
return v.Conflicts(ov)
}
// extractOrderKeys extracts transport-level conflict keys from raw tx bytes.
// The proxy's conflicts are: order relays/places/cancels touching the same
// (poolId, side, orderID), and imports claiming the same exported source UTXO.
// Parsing goes through the canonical TxParser so the key reflects the real wire
// fields (not an ad-hoc JSON probe).
func extractOrderKeys(rawTxs [][]byte) []OrderKey {
parser := &txs.TxParser{}
var keys []OrderKey
for _, raw := range rawTxs {
tx, err := parser.Parse(raw)
if err != nil {
continue
}
switch t := tx.(type) {
case *txs.PlaceOrderTx:
keys = append(keys, OrderKey{Market: marketHex(t.PoolID), Side: t.Side})
case *txs.CancelOrderTx:
keys = append(keys, OrderKey{Market: marketHex(t.PoolID), OrderID: orderIDToKey(t.OrderID)})
case *txs.RelayOrderTx:
// Relays serialize an opaque frame; bind the conflict to the
// collateral ref so two relays spending the same locked collateral
// in one round conflict.
keys = append(keys, OrderKey{Market: "relay", OrderID: t.CollateralRef})
case *txs.ImportTx:
// Each imported UTXO is a single-claim conflict key (no double-import).
for _, in := range t.ImportedInputs {
keys = append(keys, OrderKey{Market: "utxo", OrderID: in.UTXOID})
}
}
}
return keys
}
// marketHex renders a 32-byte poolId as a stable hex string for the conflict
// key (Go map keys must be comparable; a [32]byte already is, but the Market
// field is a string shared with the "utxo"/"relay" sentinels).
func marketHex(poolID [32]byte) string {
return hex.EncodeToString(poolID[:])
}
// orderIDToKey embeds a uint64 order id into an ids.ID conflict key.
func orderIDToKey(orderID uint64) ids.ID {
var id ids.ID
binary.BigEndian.PutUint64(id[:8], orderID)
return id
}
func (v *DexVertex) computeID() ids.ID {
h := sha256.New()
binary.Write(h, binary.BigEndian, v.height)
binary.Write(h, binary.BigEndian, v.epoch)
// Commit to the proposer-chosen timestamp so the vertex id binds the exact
// time fed into ProcessBlock (a tampered timestamp yields a different id, and
// two proposals with the same txs but different times don't collide).
binary.Write(h, binary.BigEndian, v.timestamp.UnixNano())
for _, p := range v.parents {
h.Write(p[:])
}
for _, raw := range v.rawTxs {
txHash := sha256.Sum256(raw)
h.Write(txHash[:])
}
// Commit to the carried fills (RED #9) so the vertex id binds the settlement
// data every validator consumes — a peer cannot swap the proposer's fills (or
// the reserved signature) while keeping the same id. The canonical encoding is
// the same one serialized into the vertex bytes.
h.Write(encodeCarriedFills(v.carriedFills, v.fillSig))
return ids.ID(h.Sum(nil))
}
// BuildVertex drains pending txs and batches non-conflicting ones.
func (cvm *ChainVM) BuildVertex(ctx context.Context) (vertex.Vertex, error) {
cvm.lock.Lock()
defer cvm.lock.Unlock()
if !cvm.initialized {
return nil, errVMNotInitialized
}
if len(cvm.pendingTxs) == 0 {
return nil, errNoBlocksBuilt
}
// Greedily batch non-conflicting txs
usedKeys := make(map[OrderKey]struct{})
var batch [][]byte
var batchKeys []OrderKey
var remaining [][]byte
for _, raw := range cvm.pendingTxs {
keys := extractOrderKeys([][]byte{raw})
conflict := false
for _, k := range keys {
if _, ok := usedKeys[k]; ok {
conflict = true
break
}
}
if conflict {
remaining = append(remaining, raw)
continue
}
for _, k := range keys {
usedKeys[k] = struct{}{}
}
batch = append(batch, raw)
batchKeys = append(batchKeys, keys...)
}
cvm.pendingTxs = remaining
if len(batch) == 0 {
return nil, errNoBlocksBuilt
}
txIDs := make([]ids.ID, len(batch))
for i, raw := range batch {
h := sha256.Sum256(raw)
txIDs[i] = ids.ID(h)
}
parentID := cvm.lastAcceptedID
// The proposer chooses the block time here (wall clock), then carries it in
// the serialized bytes so every validator's Verify uses the IDENTICAL value
// — the consensus-agreement point, exactly like the linear BuildBlock. Clamp
// to be non-decreasing vs the last processed block so block time never goes
// backwards on a re-proposal/clock skew.
ts := cvm.inner.clock.Time()
if last := cvm.inner.GetLastBlockTime(); ts.Before(last) {
ts = last
}
height := cvm.lastAcceptedHeight + 1
// Proposer build: plan + relay-once + obtain the carried fills. obtainFills is
// the single d-chain relay for this vertex, network-wide (RED #9); every
// validator settles from the carried fills below without relaying.
result, err := cvm.inner.BuildBlockResult(ctx, height, ts, batch)
if err != nil {
return nil, fmt.Errorf("dexvm: build vertex result: %w", err)
}
v := &DexVertex{
height: height,
epoch: 0,
timestamp: ts,
parents: []ids.ID{parentID},
txIDs: txIDs,
rawTxs: batch,
keys: batchKeys,
carriedFills: result.carriedFills,
fillSig: result.fillSig,
result: result,
status: choices.Processing,
vm: cvm,
}
v.id = v.computeID()
v.bytes = serializeDexVertex(v)
return v, nil
}
// ParseVertex deserializes a vertex from bytes.
func (cvm *ChainVM) ParseVertex(ctx context.Context, b []byte) (vertex.Vertex, error) {
v, err := deserializeDexVertex(b, cvm)
if err != nil {
return nil, err
}
return v, nil
}
func serializeDexVertex(v *DexVertex) []byte {
buf := make([]byte, 0, 256)
b8 := make([]byte, 8)
b4 := make([]byte, 4)
binary.BigEndian.PutUint64(b8, v.height)
buf = append(buf, b8...)
binary.BigEndian.PutUint32(b4, v.epoch)
buf = append(buf, b4...)
// Proposer-chosen block time (UnixNano) — every validator parses this exact
// value and feeds it to ProcessBlock, keeping StateRoot deterministic.
binary.BigEndian.PutUint64(b8, uint64(v.timestamp.UnixNano()))
buf = append(buf, b8...)
binary.BigEndian.PutUint32(b4, uint32(len(v.parents)))
buf = append(buf, b4...)
for _, p := range v.parents {
buf = append(buf, p[:]...)
}
binary.BigEndian.PutUint32(b4, uint32(len(v.rawTxs)))
buf = append(buf, b4...)
for _, raw := range v.rawTxs {
binary.BigEndian.PutUint32(b4, uint32(len(raw)))
buf = append(buf, b4...)
buf = append(buf, raw...)
}
// Carried-fills section (RED #9): the proposer's confirmed fills + reserved
// signature, every validator settles from these. Same encoding as the linear
// block. NETWORK-UPGRADE-GATED, LOCKSTEP vertex wire-format change.
buf = append(buf, encodeCarriedFills(v.carriedFills, v.fillSig)...)
return buf
}
func deserializeDexVertex(data []byte, cvm *ChainVM) (*DexVertex, error) {
// Minimum: height(8) + epoch(4) + timestamp(8) + parentCount(4).
if len(data) < 24 {
return nil, fmt.Errorf("dex vertex data too short: %d", len(data))
}
pos := 0
height := binary.BigEndian.Uint64(data[pos:])
pos += 8
epoch := binary.BigEndian.Uint32(data[pos:])
pos += 4
timestamp := time.Unix(0, int64(binary.BigEndian.Uint64(data[pos:])))
pos += 8
pc := binary.BigEndian.Uint32(data[pos:])
pos += 4
parents := make([]ids.ID, pc)
for i := uint32(0); i < pc; i++ {
if pos+32 > len(data) {
return nil, errInvalidBlock
}
copy(parents[i][:], data[pos:pos+32])
pos += 32
}
if pos+4 > len(data) {
return nil, errInvalidBlock
}
tc := binary.BigEndian.Uint32(data[pos:])
pos += 4
rawTxs := make([][]byte, 0, tc)
txIDs := make([]ids.ID, 0, tc)
for i := uint32(0); i < tc; i++ {
if pos+4 > len(data) {
return nil, errInvalidBlock
}
tl := binary.BigEndian.Uint32(data[pos:])
pos += 4
if pos+int(tl) > len(data) {
return nil, errInvalidBlock
}
raw := make([]byte, tl)
copy(raw, data[pos:pos+int(tl)])
rawTxs = append(rawTxs, raw)
h := sha256.Sum256(raw)
txIDs = append(txIDs, ids.ID(h))
pos += int(tl)
}
// Carried-fills section (RED #9): must be exactly consumed to end-of-vertex.
entries, sig, consumed, ferr := decodeCarriedFills(data[pos:])
if ferr != nil {
return nil, errInvalidBlock
}
if pos+consumed != len(data) {
return nil, errInvalidBlock
}
v := &DexVertex{
height: height,
epoch: epoch,
timestamp: timestamp,
parents: parents,
txIDs: txIDs,
rawTxs: rawTxs,
keys: extractOrderKeys(rawTxs),
carriedFills: entries,
fillSig: sig,
status: choices.Unknown,
vm: cvm,
bytes: data,
}
v.id = v.computeID()
return v, nil
}
-184
View File
@@ -1,184 +0,0 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dexvm
import (
"errors"
"fmt"
"github.com/luxfi/chains/dexvm/config"
"github.com/luxfi/chains/dexvm/registry"
"github.com/luxfi/ids"
)
// ConsensusModeGate is the OPTIONAL real-engine cross-check for the dexvm value-mode
// guard (H2). The dexvm itself is a STATELESS ATOMIC ZAP PROXY that moves NO value at
// runtime (see config.Config's doc) — the canonical native-value surface is the 0x9999
// cEVM, which is ALWAYS-ON (live since the Dec 25 2025 activation) with no runtime value
// gate. So the dexvm's DexConsensusMode is a STARTUP-CONFIG HYGIENE check (is the declared
// posture a legal token?), NOT a runtime value authority.
//
// When an engine gate IS injected, the dexvm value-mode CLAIM is cross-checked against the
// REAL engine — the same "derive, don't trust" discipline RealAssetsOnly already uses
// (machine-derived from the registry, never operator-attested). A config that claims
// QUORUM_FINALITY while the engine is K=1 / CFT (not actually quorum-finality) FAILS CLOSED.
// The interface is satisfied structurally by the consensus engine's *Transitive, so no
// import of consensus/engine/chain is needed here (dependency inversion).
type ConsensusModeGate interface {
// IsQuorumFinality reports whether the live consensus engine finalizes on a verified
// alpha-of-K quorum (K>1 with a vote verifier AND a cert gossiper) — the ONLY regime
// under which a QUORUM_FINALITY claim is truthful. A K=1 / CFT / degraded engine
// returns false.
IsQuorumFinality() bool
}
// ErrConsensusModeEngineMismatch is returned when the dexvm config claims QUORUM_FINALITY
// but the injected real engine is NOT in quorum-finality mode — a config posture the
// running consensus cannot back. Fail-closed (derive, don't trust).
var ErrConsensusModeEngineMismatch = errors.New("dexvm: DexConsensusMode claims QUORUM_FINALITY but the live consensus engine is not quorum-finality (K=1/CFT); refusing — the config cannot assert a finality posture the engine does not have")
// enforceRealAssetsOnly is the SINGLE backend-enforced startup gate (Gate A, the
// green-first gate). It runs once at VM Initialize, AFTER config + genesis parse and
// BEFORE the VM is marked initialized, using the node's CONSENSUS-supplied network
// identity (networkID / C-Chain / X-Chain ids) — never an operator-spoofable value.
//
// VALUE AUTHORITY (H2, the architecture boundary): the dexvm moves NO value at runtime
// (it is the stateless atomic ZAP proxy). The canonical native-value surface is the 0x9999
// cEVM, which is always-on with no runtime value gate. The DexConsensusMode guard below is
// therefore STARTUP-CONFIG HYGIENE —
// it refuses an UNSET/unknown/under-asserted mode when value is configured — and it is
// CROSS-CHECKED against the real engine when an engineMode gate is injected (a config that
// claims QUORUM_FINALITY while the engine is K=1/CFT fails closed). RealAssetsOnly is
// likewise machine-derived (the registry being real + no synthetic flag), not
// operator-attested, so neither leg of the launch posture trusts a bare config string.
//
// It enforces, fail-closed (any ambiguity refuses startup), exactly the task's gate:
//
// 1. dexAllowedAssetKinds is a subset of {EVM_NATIVE, ERC20, UTXO}.
// 2. No synthetic flag (assets/markets/mockLiquidity) on a value-bearing network
// (mainnet/testnet).
// 3. If a per-network manifest is configured, every asset binds to the node's running
// C-Chain/X-Chain identity (RuntimeVerifier — a real check), every market resolves
// to two registered real assets, and the residual deny-scan (Liquidity universe,
// mock/phantom liquidity, ASCII-ticker id, declared-credit) passes.
// 4. The consensus-mode value guard: if native value is enabled it activates ONLY
// under QUORUM_FINALITY or a fully-asserted HONEST_VALIDATOR_LABELED; otherwise
// startup is refused. When an engineMode gate is injected, a QUORUM_FINALITY claim
// is additionally cross-checked against the live engine and fails closed on mismatch.
//
// engineMode is optional (variadic, 0-or-1): with none injected the value-mode guard is
// config-hygiene only (correct for the non-value proxy, whose value never moves); with one
// injected the QUORUM_FINALITY claim must match the real engine.
//
// It returns the value-mode status (the no-Byzantine-finality disclaimer for a labeled
// CFT-parity launch, empty for QUORUM_FINALITY) so the caller can surface it, and an
// error that the VM turns into a hard init failure.
func enforceRealAssetsOnly(
cfg config.Config,
networkID uint32,
cChainID ids.ID,
xChainID ids.ID,
engineMode ...ConsensusModeGate,
) (registry.ValueModeStatus, error) {
// Build the locked-down policy from config (every flag SAFE-by-default).
policy, err := assetPolicyFromConfig(cfg)
if err != nil {
return registry.ValueModeStatus{}, err
}
class := registry.NetworkClassFor(networkID)
// Build the registry from the configured manifest (if any). A configured manifest is
// loaded, identity-bound to the running chain (RuntimeVerifier), and admitted; with
// no manifest the registry is empty (no markets can be enabled, which is itself
// fail-closed). Admission and gating are orthogonal: AdmitInto only registers, then
// the startup gate runs EXACTLY ONCE below regardless of path.
reg := registry.New(policy.AllowedKindsOrDefault()...)
var chainLabelFor func(ids.ID) string
if cfg.DexAssetManifestPath != "" {
// M1: load the manifest PINNED to its CI-approved content hash when configured. An
// edited local manifest (a fabricated token address) no longer matches the pin and
// fails startup. With no pin set it falls back to shape-only load (LoadManifestPinned
// with "" == LoadManifest), so pinning is opt-in but mandatory-when-set.
m, lerr := registry.LoadManifestPinned(cfg.DexAssetManifestPath, cfg.DexAssetManifestSHA256)
if lerr != nil {
return registry.ValueModeStatus{}, fmt.Errorf("dex asset manifest: %w", lerr)
}
rv, verr := registry.NewRuntimeVerifier(networkID, cChainID, xChainID, m)
if verr != nil {
return registry.ValueModeStatus{}, fmt.Errorf("dex asset manifest identity bind: %w", verr)
}
if aerr := m.AdmitInto(reg, rv); aerr != nil {
return registry.ValueModeStatus{}, fmt.Errorf("dex asset manifest admit: %w", aerr)
}
chainLabelFor = m.ChainLabelFor()
}
// THE single fail-closed startup gate: no synthetic flag on a value net, every enabled
// market resolves to two registered real assets, no Liquidity/mock/ticker reference,
// allowed-kinds a subset of the three. Runs once over whatever was admitted (or the
// empty registry when no manifest is configured).
if gerr := registry.RefuseUnderSyntheticConfig(class, policy, reg, chainLabelFor); gerr != nil {
return registry.ValueModeStatus{}, gerr
}
// Native value cannot be activated with no real declared assets — a value launch
// with an empty registry is a misconfiguration, refused.
if cfg.DexNativeValueEnabled && reg.Len() == 0 {
return registry.ValueModeStatus{}, fmt.Errorf(
"refuse DEX native value activation: no real assets registered (manifest path=%q) — value requires declared real on-chain assets",
cfg.DexAssetManifestPath)
}
// Consensus-mode value guard. RealAssetsOnly is MACHINE-DERIVED (registry is real +
// no synthetic flag enabled), not operator-attested, so the operator cannot lie about
// it under HONEST_VALIDATOR_LABELED.
mode, err := registry.ParseConsensusMode(cfg.DexConsensusMode)
if err != nil {
return registry.ValueModeStatus{}, err
}
assertions := registry.LaunchAssertions{
CapsOn: cfg.DexCapsOn,
RealAssetsOnly: !policy.AnySyntheticFlag(), // registry already proven real above
HaltReady: cfg.DexHaltReady,
}
status, err := registry.GuardValueActivation(cfg.DexNativeValueEnabled, mode, assertions)
if err != nil {
return registry.ValueModeStatus{}, err
}
// H2 (derive, don't trust): when an engine-mode gate is injected, a QUORUM_FINALITY
// CLAIM is cross-checked against the REAL consensus engine. A config that claims
// quorum-finality while the live engine is K=1 / CFT (not actually quorum-finality)
// FAILS CLOSED — the config cannot assert a finality posture the engine does not have.
// This binds the dexvm's value-mode CLAIM to the same engine the cEVM value gate (H1)
// reads, so the two cannot disagree. (HONEST_VALIDATOR_LABELED is the LABELED CFT-parity
// mode and is NOT cross-checked against quorum-finality — it explicitly claims no
// Byzantine finality; only the QUORUM_FINALITY claim is engine-bound.)
if cfg.DexNativeValueEnabled && mode == registry.ConsensusModeQuorumFinality && len(engineMode) > 0 {
if gate := engineMode[0]; gate != nil && !gate.IsQuorumFinality() {
return registry.ValueModeStatus{}, ErrConsensusModeEngineMismatch
}
}
return status, nil
}
// assetPolicyFromConfig translates the VM config's real-assets flags into the registry
// policy, parsing the allowed-kind tokens (an unknown token fails closed here).
func assetPolicyFromConfig(cfg config.Config) (registry.DexAssetPolicy, error) {
kinds := make([]registry.AssetKind, 0, len(cfg.DexAllowedAssetKinds))
for _, tok := range cfg.DexAllowedAssetKinds {
k, err := registry.ParseAssetKind(tok)
if err != nil {
return registry.DexAssetPolicy{}, fmt.Errorf("dexAllowedAssetKinds: %w", err)
}
kinds = append(kinds, k)
}
return registry.DexAssetPolicy{
AllowSyntheticAssets: cfg.DexAllowSyntheticAssets,
AllowSyntheticMarkets: cfg.DexAllowSyntheticMarkets,
AllowMockLiquidity: cfg.DexAllowMockLiquidity,
AllowedAssetKinds: kinds,
}, nil
}
-130
View File
@@ -1,130 +0,0 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dexvm
import (
"errors"
"testing"
"github.com/luxfi/chains/dexvm/config"
"github.com/luxfi/chains/dexvm/registry"
"github.com/luxfi/ids"
)
// enforce_h2_test.go is the H2 proof: the dexvm value-mode CLAIM is cross-checked against
// the REAL consensus engine, not trusted from the config string. A config that claims
// QUORUM_FINALITY while the live engine is K=1 / CFT (not actually quorum-finality) FAILS
// CLOSED — the same "derive, don't trust" discipline RealAssetsOnly already uses. This binds
// the dexvm's posture to the same engine the cEVM value gate (H1) reads.
// fakeEngineMode is a ConsensusModeGate stand-in for the consensus engine's *Transitive
// (whose Mode()==ModeQuorumFinality is the truthful quorum signal). It reports a chosen
// finality regime so the cross-check's both branches are exercised.
type fakeEngineMode struct{ quorum bool }
func (g fakeEngineMode) IsQuorumFinality() bool { return g.quorum }
// TestH2_QuorumFinalityClaim_K1Engine_FailsClosed is the decisive proof: the config claims
// QUORUM_FINALITY, the manifest + assets are clean, but the injected engine is NOT
// quorum-finality (K=1 / CFT). Startup MUST be refused — the config cannot assert a
// finality posture the engine does not have.
func TestH2_QuorumFinalityClaim_K1Engine_FailsClosed(t *testing.T) {
cChain := ids.GenerateTestID()
cfg := config.DefaultConfig()
cfg.DexAssetManifestPath = writeRealManifest(t, mainnetNet, cChain, false)
cfg.DexNativeValueEnabled = true
cfg.DexConsensusMode = "QUORUM_FINALITY"
// The real engine is K=1 / CFT — NOT quorum-finality. The config claim is a lie.
k1Engine := fakeEngineMode{quorum: false}
if _, err := enforceRealAssetsOnly(cfg, mainnetNet, cChain, ids.Empty, k1Engine); !errors.Is(err, ErrConsensusModeEngineMismatch) {
t.Fatalf("a QUORUM_FINALITY config claim on a K=1/CFT engine must fail closed (ErrConsensusModeEngineMismatch), got: %v", err)
}
}
// TestH2_QuorumFinalityClaim_QuorumEngine_Passes proves the cross-check does not over-reject:
// a QUORUM_FINALITY config claim on a genuinely quorum-finality engine admits, with no
// disclaimer (the finality is real).
func TestH2_QuorumFinalityClaim_QuorumEngine_Passes(t *testing.T) {
cChain := ids.GenerateTestID()
cfg := config.DefaultConfig()
cfg.DexAssetManifestPath = writeRealManifest(t, mainnetNet, cChain, false)
cfg.DexNativeValueEnabled = true
cfg.DexConsensusMode = "QUORUM_FINALITY"
quorumEngine := fakeEngineMode{quorum: true}
st, err := enforceRealAssetsOnly(cfg, mainnetNet, cChain, ids.Empty, quorumEngine)
if err != nil {
t.Fatalf("a QUORUM_FINALITY claim on a quorum-finality engine must pass: %v", err)
}
if st.Mode != registry.ConsensusModeQuorumFinality {
t.Fatalf("wrong value mode: %s", st.Mode)
}
if st.Status != "" {
t.Fatalf("QUORUM_FINALITY must not surface a disclaimer, got %q", st.Status)
}
}
// TestH2_HonestValidatorLabeled_NotCrossCheckedAgainstQuorum proves the LABELED CFT-parity
// mode is NOT cross-checked against quorum-finality: it explicitly claims no Byzantine
// finality, so a K=1/CFT engine is consistent with it. The labeled launch posture admits
// (with its disclaimer) even on a non-quorum engine — only the QUORUM_FINALITY claim is
// engine-bound.
func TestH2_HonestValidatorLabeled_NotCrossCheckedAgainstQuorum(t *testing.T) {
cChain := ids.GenerateTestID()
cfg := config.DefaultConfig()
cfg.DexAssetManifestPath = writeRealManifest(t, mainnetNet, cChain, false)
cfg.DexNativeValueEnabled = true
cfg.DexConsensusMode = "HONEST_VALIDATOR_LABELED"
cfg.DexCapsOn = true
cfg.DexHaltReady = true
// Even on a K=1/CFT engine, the LABELED CFT-parity launch is consistent (it claims no
// Byzantine finality). It admits with the no-Byzantine-finality disclaimer.
k1Engine := fakeEngineMode{quorum: false}
st, err := enforceRealAssetsOnly(cfg, mainnetNet, cChain, ids.Empty, k1Engine)
if err != nil {
t.Fatalf("HONEST_VALIDATOR_LABELED must admit on a CFT engine (it claims no Byzantine finality): %v", err)
}
if st.Status != registry.NoByzantineFinalityClaim {
t.Fatalf("HONEST_VALIDATOR_LABELED must surface the no-Byzantine-finality disclaimer, got %q", st.Status)
}
}
// TestM1_Enforce_TamperedManifestWithPin_FailsStartup proves M1 end-to-end at the startup
// gate: with a manifest content hash pinned in config, a manifest whose bytes were edited
// (here, simply a different on-disk file than the pin) FAILS enforceRealAssetsOnly. The
// pin is the dexvm's defense (it has no EVM state to eth_getCode the tokens).
func TestM1_Enforce_TamperedManifestWithPin_FailsStartup(t *testing.T) {
cChain := ids.GenerateTestID()
cfg := config.DefaultConfig()
cfg.DexAssetManifestPath = writeRealManifest(t, mainnetNet, cChain, false)
// Pin a DIFFERENT hash than the file actually has (simulating a file edited away from the
// CI-approved artifact whose hash was pinned).
cfg.DexAssetManifestSHA256 = "0000000000000000000000000000000000000000000000000000000000000000"
if _, err := enforceRealAssetsOnly(cfg, mainnetNet, cChain, ids.Empty); err == nil {
t.Fatal("a manifest whose content does not match the pinned hash must fail startup")
} else if !errors.Is(err, registry.ErrManifestHashMismatch) {
t.Fatalf("expected ErrManifestHashMismatch, got: %v", err)
}
}
// TestH2_NoEngineGate_ConfigHygieneOnly proves backward-compatibility: with NO engine gate
// injected (the variadic absent), the value-mode guard is config-hygiene only — exactly the
// pre-H2 behavior — because the dexvm is the non-value proxy (its value never moves; the
// cEVM/H1 is the runtime value authority). A QUORUM_FINALITY config admits with no engine
// to contradict it.
func TestH2_NoEngineGate_ConfigHygieneOnly(t *testing.T) {
cChain := ids.GenerateTestID()
cfg := config.DefaultConfig()
cfg.DexAssetManifestPath = writeRealManifest(t, mainnetNet, cChain, false)
cfg.DexNativeValueEnabled = true
cfg.DexConsensusMode = "QUORUM_FINALITY"
// No engine gate -> config hygiene only (the proxy moves no value).
if _, err := enforceRealAssetsOnly(cfg, mainnetNet, cChain, ids.Empty); err != nil {
t.Fatalf("with no engine gate the value-mode guard is config-hygiene only and must admit a legal mode: %v", err)
}
}
-233
View File
@@ -1,233 +0,0 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// enforce_test.go — Gate A end-to-end: the backend-enforced real-assets-only startup
// gate. These tests drive enforceRealAssetsOnly exactly as VM.Initialize does, proving
// it REFUSES synthetic flags on value nets, value activation without a legal consensus
// mode, a wrong-net / Liquidity manifest, and a value launch with no real assets — and
// ADMITS a clean manifest under a legal value mode.
package dexvm
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/luxfi/chains/dexvm/config"
"github.com/luxfi/chains/dexvm/registry"
"github.com/luxfi/ids"
)
const (
mainnetNet uint32 = 1
devNet uint32 = 1337
)
func addr20(seed byte) []byte {
b := make([]byte, 20)
for i := range b {
b[i] = seed + byte(i) + 1
}
return b
}
// writeRealManifest writes a manifest with two real ERC-20 assets and their market on
// the given C-Chain, returning the path. The assets are "real" for the node gate via
// the RuntimeVerifier identity bind (per-token existence is the CI gate, not the node).
func writeRealManifest(t *testing.T, networkID uint32, cChain ids.ID, liquidityLabel bool) string {
t.Helper()
wlux := addr20(0x4a)
lusd := addr20(0x84)
wluxID, _ := registry.DeriveAssetID(networkID, cChain, registry.AssetKindERC20, wlux)
lusdID, _ := registry.DeriveAssetID(networkID, cChain, registry.AssetKindERC20, lusd)
label := "Lux C-Chain"
if liquidityLabel {
label = "Liquidity primary network"
}
m := registry.Manifest{
Network: "mainnet",
NetworkID: networkID,
EVMChainID: 96369,
CChainID: cChain,
ChainLabels: map[string]string{cChain.Hex(): label},
Assets: []registry.Asset{
{NetworkID: networkID, ChainID: cChain, Kind: registry.AssetKindERC20, CanonicalRef: wlux, Decimals: 18, Symbol: "WLUX", Name: "Wrapped LUX", Enabled: true, RiskTier: registry.RiskTier0},
{NetworkID: networkID, ChainID: cChain, Kind: registry.AssetKindERC20, CanonicalRef: lusd, Decimals: 18, Symbol: "LUSD", Name: "Lux Dollar", Enabled: true, RiskTier: registry.RiskTier0},
},
Markets: []registry.Market{
{NetworkID: networkID, BaseAssetID: wluxID, QuoteAssetID: lusdID, VenueConfig: []byte("tick=1;lot=1;fee=30"), Enabled: true},
},
}
b, err := json.MarshalIndent(m, "", " ")
if err != nil {
t.Fatalf("marshal manifest: %v", err)
}
path := filepath.Join(t.TempDir(), "assets.mainnet.json")
if err := os.WriteFile(path, b, 0o600); err != nil {
t.Fatalf("write manifest: %v", err)
}
return path
}
// TestGateA_CleanManifestUnderQuorumFinality is the happy path: a real manifest on the
// node's running C-Chain, value enabled under QUORUM_FINALITY, admits and surfaces no
// disclaimer.
func TestGateA_CleanManifestUnderQuorumFinality(t *testing.T) {
cChain := ids.GenerateTestID()
cfg := config.DefaultConfig()
cfg.DexAssetManifestPath = writeRealManifest(t, mainnetNet, cChain, false)
cfg.DexNativeValueEnabled = true
cfg.DexConsensusMode = "QUORUM_FINALITY"
st, err := enforceRealAssetsOnly(cfg, mainnetNet, cChain, ids.GenerateTestID())
if err != nil {
t.Fatalf("clean manifest under QUORUM_FINALITY must pass: %v", err)
}
if st.Mode != registry.ConsensusModeQuorumFinality {
t.Fatalf("wrong value mode: %s", st.Mode)
}
if st.Status != "" {
t.Fatalf("QUORUM_FINALITY must not surface a disclaimer, got %q", st.Status)
}
}
// TestGateA_HonestValidatorLabeledSurfacesDisclaimer proves the labeled CFT-parity mode
// activates only with caps-on + halt-ready (real-assets-only is machine-derived) and
// surfaces the exact no-Byzantine-finality string.
func TestGateA_HonestValidatorLabeledSurfacesDisclaimer(t *testing.T) {
cChain := ids.GenerateTestID()
base := config.DefaultConfig()
base.DexAssetManifestPath = writeRealManifest(t, mainnetNet, cChain, false)
base.DexNativeValueEnabled = true
base.DexConsensusMode = "HONEST_VALIDATOR_LABELED"
// Missing caps => refused.
noCaps := base
noCaps.DexCapsOn = false
noCaps.DexHaltReady = true
if _, err := enforceRealAssetsOnly(noCaps, mainnetNet, cChain, ids.Empty); !errors.Is(err, registry.ErrLaunchAssertionsUnmet) {
t.Fatalf("HONEST_VALIDATOR_LABELED without caps must refuse, got: %v", err)
}
// Full bundle => admitted with the disclaimer.
full := base
full.DexCapsOn = true
full.DexHaltReady = true
st, err := enforceRealAssetsOnly(full, mainnetNet, cChain, ids.Empty)
if err != nil {
t.Fatalf("HONEST_VALIDATOR_LABELED with full bundle must pass: %v", err)
}
if st.Status != registry.NoByzantineFinalityClaim {
t.Fatalf("must surface no-Byzantine-finality disclaimer, got %q", st.Status)
}
}
// TestGateA_RefusesSyntheticFlagOnMainnet proves a synthetic flag fails startup on a
// value-bearing network, regardless of manifest.
func TestGateA_RefusesSyntheticFlagOnMainnet(t *testing.T) {
cChain := ids.GenerateTestID()
cfg := config.DefaultConfig()
cfg.DexAssetManifestPath = writeRealManifest(t, mainnetNet, cChain, false)
cfg.DexAllowSyntheticAssets = true // forbidden on mainnet
if _, err := enforceRealAssetsOnly(cfg, mainnetNet, cChain, ids.Empty); !errors.Is(err, registry.ErrSyntheticOnValueNet) {
t.Fatalf("synthetic flag on mainnet must refuse startup, got: %v", err)
}
// On a DEV network the same flag is permitted (developer opt-in). Use a dev manifest
// (devNet) so the manifest's networkID matches.
devCfg := config.DefaultConfig()
devCfg.DexAssetManifestPath = writeRealManifest(t, devNet, cChain, false)
devCfg.DexAllowSyntheticAssets = true
if _, err := enforceRealAssetsOnly(devCfg, devNet, cChain, ids.Empty); err != nil {
t.Fatalf("dev network must permit synthetic flag: %v", err)
}
}
// TestGateA_RefusesValueWithoutLegalMode proves native value cannot activate under an
// UNSET or unknown consensus mode.
func TestGateA_RefusesValueWithoutLegalMode(t *testing.T) {
cChain := ids.GenerateTestID()
cfg := config.DefaultConfig()
cfg.DexAssetManifestPath = writeRealManifest(t, mainnetNet, cChain, false)
cfg.DexNativeValueEnabled = true
cfg.DexConsensusMode = "" // UNSET
if _, err := enforceRealAssetsOnly(cfg, mainnetNet, cChain, ids.Empty); !errors.Is(err, registry.ErrValueModeUnset) {
t.Fatalf("value with UNSET mode must refuse, got: %v", err)
}
cfg.DexConsensusMode = "PARTIAL_FINALITY" // unknown token
if _, err := enforceRealAssetsOnly(cfg, mainnetNet, cChain, ids.Empty); err == nil {
t.Fatal("value with unknown consensus mode must refuse")
}
}
// TestGateA_RefusesWrongNetManifest proves the RuntimeVerifier identity bind catches a
// manifest built for a different C-Chain than the node is running.
func TestGateA_RefusesWrongNetManifest(t *testing.T) {
manifestChain := ids.GenerateTestID()
runningChain := ids.GenerateTestID() // node runs a DIFFERENT C-Chain
cfg := config.DefaultConfig()
cfg.DexAssetManifestPath = writeRealManifest(t, mainnetNet, manifestChain, false)
if _, err := enforceRealAssetsOnly(cfg, mainnetNet, runningChain, ids.Empty); err == nil {
t.Fatal("manifest rooted at a C-Chain the node does not run must refuse startup")
} else if !strings.Contains(err.Error(), "wrong-chain") {
t.Fatalf("expected wrong-chain identity-bind error, got: %v", err)
}
}
// TestGateA_RefusesLiquidityManifest proves a Liquidity (white-label) universe label is
// refused even when the token is otherwise structurally real.
func TestGateA_RefusesLiquidityManifest(t *testing.T) {
cChain := ids.GenerateTestID()
cfg := config.DefaultConfig()
cfg.DexAssetManifestPath = writeRealManifest(t, mainnetNet, cChain, true) // Liquidity label
if _, err := enforceRealAssetsOnly(cfg, mainnetNet, cChain, ids.Empty); err == nil {
t.Fatal("manifest labeling its chain a Liquidity universe must refuse startup")
}
}
// TestGateA_RefusesValueWithNoRealAssets proves value cannot activate with an empty
// registry (no manifest).
func TestGateA_RefusesValueWithNoRealAssets(t *testing.T) {
cfg := config.DefaultConfig()
cfg.DexNativeValueEnabled = true
cfg.DexConsensusMode = "QUORUM_FINALITY"
cfg.DexAssetManifestPath = "" // no real assets declared
if _, err := enforceRealAssetsOnly(cfg, mainnetNet, ids.GenerateTestID(), ids.Empty); err == nil {
t.Fatal("value activation with no real declared assets must refuse startup")
}
}
// TestGateA_RefusesBadAllowedKind proves a non-real allowed-kind token fails startup.
func TestGateA_RefusesBadAllowedKind(t *testing.T) {
cfg := config.DefaultConfig()
cfg.DexAllowedAssetKinds = []string{"ERC20", "D_NATIVE"} // D_NATIVE is not a real kind
if _, err := enforceRealAssetsOnly(cfg, mainnetNet, ids.GenerateTestID(), ids.Empty); err == nil {
t.Fatal("non-real allowed-kind token must refuse startup")
}
}
// TestGateA_PaperModeNoManifestOK proves the default locked-down config (paper mode, no
// value, no manifest) starts fine on every network class — Gate A does not block a
// non-value deployment.
func TestGateA_PaperModeNoManifestOK(t *testing.T) {
for _, net := range []uint32{mainnetNet, 2 /*testnet*/, devNet} {
cfg := config.DefaultConfig()
st, err := enforceRealAssetsOnly(cfg, net, ids.GenerateTestID(), ids.Empty)
if err != nil {
t.Fatalf("paper-mode default config must start on net %d: %v", net, err)
}
if st.Status != "" {
t.Fatalf("paper mode surfaces no value disclaimer, got %q", st.Status)
}
}
}
-60
View File
@@ -1,60 +0,0 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package dexvm implements a high-performance decentralized exchange VM
// for the Lux blockchain network.
//
// The DEX VM provides:
// - Central Limit Order Book (CLOB) trading with nanosecond price updates
// - Automated Market Maker (AMM) liquidity pools
// - Cross-chain atomic swaps via Warp messaging
// - 200ms block times for high-frequency trading
// - LX-First arbitrage strategy support
//
// Architecture:
// - Finalizes via the linear consensus engine (Photon → Wave → Focus) and
// the ⅔-by-stake BFT certificate path, identical to C-Chain — the chain
// manager routes dexvm through the chain.ChainVM linear runtime.
// - Integrates with Warp 1.5 for cross-chain messaging
// - Supports both spot and perpetual trading
// - Designed for institutional-grade performance
package dexvm
import (
"github.com/luxfi/accel"
"github.com/luxfi/chains/dexvm/config"
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/node/vms"
)
var (
// VMID is the unique identifier for the DEX VM
VMID = ids.ID{'d', 'e', 'x', 'v', 'm'}
_ vms.Factory = (*Factory)(nil)
)
// Factory creates new DEX VM instances.
type Factory struct {
config.Config
}
// New implements vms.Factory interface.
// It creates a new DEX ChainVM instance with the factory's configuration.
// The ChainVM wrapper implements block.ChainVM for integration with the chains manager.
// Allocates a per-VM GPU session at PriorityHigh because the DEX hot path
// is latency-critical (1ms block times).
func (f *Factory) New(logger log.Logger) (interface{}, error) {
sess, err := accel.NewVMSession("dexvm", accel.WithPriority(accel.PriorityHigh))
if err != nil {
return nil, err
}
// Create the ChainVM wrapper which implements block.ChainVM
chainVM := NewChainVM(logger)
// Apply factory config to inner VM
chainVM.inner.Config = f.Config
chainVM.inner.accel = sess
return chainVM, nil
}
-145
View File
@@ -1,145 +0,0 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dexvm
import (
"encoding/json"
"fmt"
"github.com/luxfi/chains/dexvm/txs"
"github.com/luxfi/constants"
"github.com/luxfi/ids"
"github.com/luxfi/node/vms/types/fee"
)
// LUXAssetID is the canonical L0 settlement asset: the brand-neutral LUX UTXO
// asset literal (constants.UTXO_ASSET_ID). It is NETWORK-INDEPENDENT by design —
// every chain that runs the D-Chain DEX, including an L2/L3 with its own native
// currency on top of LUX, denominates DEX fees in this ONE asset, so DEX fees
// always settle to LUX regardless of the local native token.
//
// The D-Chain is UTXO-based, so the canonical UTXO asset id is the correct
// reference (an EVM-context DEX would settle in the EVM-native LUX instead).
// This is deliberately the ONE canonical literal — NOT UTXOAssetIDFor(localID)
// (the local chain's own token) nor the per-network function pinned to mainnet.
var LUXAssetID ids.ID = constants.UTXO_ASSET_ID
// newFeePolicy returns the canonical D-Chain FeePolicy: a FlatPolicy at
// MinTxFeeFloor (1 mLUX = 1_000_000 nLUX) denominated in canonical LUX. D-Chain
// is user-tx-accepting so it MUST charge a non-zero floor — see
// vms/types/fee/policy.go.
//
// The fee asset is LUX for EVERY network (the L0 settlement token), never the
// local network's native UTXO asset: an L2/L3 building on LUX pays DEX fees that
// settle to LUX. The policy is therefore network-independent — it takes no
// networkID, which is exactly the property "LUX is the lowest-level currency"
// encodes.
func newFeePolicy() fee.Policy {
return fee.FlatPolicy{
Fee: fee.MinTxFeeFloor,
AssetID: LUXAssetID,
}
}
// txFee returns the LUX-denominated fee a DEX BaseTx pays.
// In dexvm the per-tx burn is GasPrice * GasLimit; both fields live on
// BaseTx so every concrete Tx (PlaceOrderTx, SwapTx, ...) inherits them.
func txFee(base txs.BaseTx) uint64 {
// Saturating multiply — overflow means "definitely above any floor".
hi, lo := mul64Overflow(base.GasPrice, base.GasLimit)
if hi != 0 {
return ^uint64(0)
}
return lo
}
// mul64Overflow returns hi:lo = a*b. Hot-path constant-time integer mul,
// no allocations, no branches on values.
func mul64Overflow(a, b uint64) (hi, lo uint64) {
const mask = uint64(0xffffffff)
a0, a1 := a&mask, a>>32
b0, b1 := b&mask, b>>32
w0 := a0 * b0
t := a1*b0 + (w0 >> 32)
w1 := t & mask
w2 := t >> 32
w1 += a0 * b1
hi = a1*b1 + w2 + (w1 >> 32)
lo = a * b
return
}
// gateUserTxBytes admits a raw user-tx blob iff it parses to a BaseTx
// whose declared fee satisfies the configured FeePolicy. Called from
// SubmitTx — the single user-mempool entry on D-Chain.
//
// Internal callers (consensus engine -> VM, replay path) bypass this
// gate by feeding pendingTxs directly; the gate only fires on the
// public bytes-in interface.
func (cvm *ChainVM) gateUserTxBytes(b []byte) error {
if cvm.feePolicy == nil {
return fmt.Errorf("dexvm: fee policy not initialized")
}
var base txs.BaseTx
if err := json.Unmarshal(b, &base); err != nil {
return fmt.Errorf("dexvm: tx decode: %w", err)
}
// Fees are denominated in canonical LUX, NOT the local network's native
// asset — the floor is enforced in LUX so every chain settles DEX fees to
// the L0 token. Deterministic: a pure integer + asset-id compare against a
// compile-time constant, identical on every validator and on every network.
return cvm.feePolicy.ValidateFee(txFee(base), LUXAssetID)
}
// FeePolicy exposes the chain's declared fee policy for diagnostics and
// the boot-time Validate gate.
func (cvm *ChainVM) FeePolicy() fee.Policy { return cvm.feePolicy }
// swapNativeFeeToLUX converts a fee TENDERED in a non-LUX native asset into its
// canonical-LUX settlement amount, swapping native -> LUX at a CONFIRMED matcher
// fill price (the proxy's own swap path — eat-your-own-dogfood). luxPerNative is
// the Fill.Price of a confirmed native->LUX fill: a uint64 FIXED-POINT ×PriceScale
// value (LUX quote per native base). It is a consensus-agreed receipt, so every
// validator computes the identical LUX amount from identical inputs — deterministic
// settlement with NO float on the value path.
//
// The LUX output is the asset the fee sink RECEIVES, so it is the exact quote the
// fill realized: floor(nativeFee × luxPerNative / PriceScale) via quoteUnits — the
// SAME per-fill floor settleFromFills uses for every credited leg (never credit a
// LUX unit the fill did not realize; the proxy never mints LUX). Flooring is the
// floor-SAFE direction: a caller's floor check can never be passed by rounding
// error, only failed by it. A zero price is a malformed fill and is refused.
func swapNativeFeeToLUX(nativeFee uint64, luxPerNative uint64) (uint64, error) {
if luxPerNative == 0 {
return 0, fmt.Errorf("dexvm: fee swap: invalid LUX/native price %d", luxPerNative)
}
return settleUint64(quoteUnits(nativeFee, luxPerNative), "fee-swap LUX")
}
// settleFeeInLUX sources the D-Chain tx fee in canonical LUX from a fee tendered
// in the local native asset, by swapping native -> LUX through a CONFIRMED
// matcher fill (the DEX's own swap path). LUX is the canonical sink: the returned
// amount is the LUX the fee settles to, and it MUST clear the LUX floor
// (MinTxFeeFloor) or the fee is insufficient.
//
// This is the SETTLE-time counterpart of gateUserTxBytes: the gate enforces the
// floor in LUX on a LUX-denominated tender at mempool admission (deterministic,
// no price needed); settleFeeInLUX enforces the SAME floor in LUX on the swapped
// output for a native tender at block execution (deterministic against the
// confirmed fill). The floor therefore holds whether the fee is paid in LUX
// directly or swapped from a native token — it cannot be bypassed by tendering
// native.
func (cvm *ChainVM) settleFeeInLUX(nativeFee uint64, luxPerNative uint64) (uint64, error) {
if cvm.feePolicy == nil {
return 0, fmt.Errorf("dexvm: fee policy not initialized")
}
luxOut, err := swapNativeFeeToLUX(nativeFee, luxPerNative)
if err != nil {
return 0, err
}
if err := cvm.feePolicy.ValidateFee(luxOut, LUXAssetID); err != nil {
return 0, err
}
return luxOut, nil
}
-185
View File
@@ -1,185 +0,0 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dexvm
import (
"context"
"encoding/json"
"errors"
"testing"
"github.com/luxfi/chains/dexvm/txs"
"github.com/luxfi/constants"
"github.com/luxfi/database/memdb"
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/node/vms/types/fee"
"github.com/luxfi/runtime"
"github.com/luxfi/vm"
"github.com/luxfi/warp"
)
// luxL2NetworkID is a sovereign L1/L2/L3 primary-network id that is NOT the Lux
// primary network (1). A chain on this id has its OWN native UTXO asset
// (UTXOAssetIDFor(luxL2NetworkID)), distinct from LUX — exactly the case the CTO
// directive targets: "any L2 or L3 building with native currency on top of LUX".
const luxL2NetworkID uint32 = 8675309
// newChainVMForNetwork returns a fully-initialized ChainVM pinned to networkID,
// so a test can assert the fee policy on an arbitrary network (the primary
// network or an L2/L3 with its own native token).
func newChainVMForNetwork(t *testing.T, networkID uint32) *ChainVM {
t.Helper()
logger := log.NewNoOpLogger()
cvm := NewChainVM(logger)
rt := &runtime.Runtime{
ChainID: ids.GenerateTestID(),
NetworkID: networkID,
Log: logger,
}
if err := cvm.Initialize(context.Background(), vm.Init{
Runtime: rt,
DB: memdb.New(),
ToEngine: make(chan vm.Message, 8),
Sender: warp.FakeSender{},
Log: logger,
}); err != nil {
t.Fatalf("init dexvm (network %d): %v", networkID, err)
}
return cvm
}
// TestFeeAsset_IsCanonicalLUX_NotLocalNative is the core property: the DEX fee
// asset is canonical LUX on EVERY network — the Lux primary network AND an L2/L3
// whose own native token differs from LUX. Before this change the fee was
// denominated in constants.UTXOAssetIDFor(localNetworkID) (the local native
// token); now it is always LUX, so DEX fees settle to the L0 token regardless of
// the chain's native currency.
func TestFeeAsset_IsCanonicalLUX_NotLocalNative(t *testing.T) {
// Canonical LUX is the PRIMARY-network (id 1) UTXO asset.
wantLUX := constants.UTXOAssetIDFor(constants.MainnetID)
if LUXAssetID != wantLUX {
t.Fatalf("LUXAssetID = %s, want UTXOAssetIDFor(1) = %s", LUXAssetID, wantLUX)
}
// (1) Lux primary network: fee asset is LUX (and here LUX == the local
// native asset, since the local network IS the primary network).
primary := newChainVMForNetwork(t, constants.MainnetID)
if got := primary.FeePolicy().FeeAssetID(); got != LUXAssetID {
t.Fatalf("primary-network fee asset = %s, want LUX %s", got, LUXAssetID)
}
// (2) L2/L3 network: its OWN native token differs from LUX, yet the fee
// asset is STILL LUX. This is the whole point of the directive.
localNative := constants.UTXOAssetIDFor(luxL2NetworkID)
if localNative == LUXAssetID {
t.Fatalf("test precondition broken: L2/L3 native %s must differ from LUX %s", localNative, LUXAssetID)
}
l2 := newChainVMForNetwork(t, luxL2NetworkID)
if got := l2.FeePolicy().FeeAssetID(); got != LUXAssetID {
t.Fatalf("L2/L3 fee asset = %s, want LUX %s (NOT local native %s)", got, LUXAssetID, localNative)
}
// The floor is unchanged and the policy still validates (non-zero floor).
if got := l2.FeePolicy().MinTxFee(); got != fee.MinTxFeeFloor {
t.Fatalf("L2/L3 MinTxFee = %d, want %d", got, fee.MinTxFeeFloor)
}
if err := fee.Validate(l2.FeePolicy()); err != nil {
t.Fatalf("fee.Validate(L2/L3 policy) = %v, want nil", err)
}
}
// TestFeeFloor_HoldsInLUX_OnL2 confirms the consensus fee-floor invariant still
// holds — in LUX terms — on an L2/L3 network. An under-floor fee is rejected with
// ErrInsufficientFee (in LUX, not the local native token), and a fee at the floor
// is admitted. The gate is not weakened by switching the denomination to LUX.
func TestFeeFloor_HoldsInLUX_OnL2(t *testing.T) {
l2 := newChainVMForNetwork(t, luxL2NetworkID)
// Under-floor: GasPrice*GasLimit < MinTxFeeFloor -> rejected in LUX terms.
under := &txs.PlaceOrderTx{BaseTx: txs.BaseTx{
TxType: txs.TxPlaceOrder,
From: ids.GenerateTestShortID(),
Nonce: 1,
GasPrice: 1,
GasLimit: 1, // fee = 1 nLUX << floor
}, PoolID: [32]byte{1}, Side: 0, Price: 1, Size: 1}
b, err := json.Marshal(under)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if err := l2.SubmitTx(b); !errors.Is(err, fee.ErrInsufficientFee) {
t.Fatalf("SubmitTx(under-floor on L2) = %v, want ErrInsufficientFee", err)
}
// At the floor: admitted (fee == MinTxFeeFloor, denominated in LUX).
atFloor := &txs.PlaceOrderTx{BaseTx: txs.BaseTx{
TxType: txs.TxPlaceOrder,
From: ids.GenerateTestShortID(),
Nonce: 2,
GasPrice: 1_000,
GasLimit: 1_000, // fee = 1_000_000 nLUX == floor
}, PoolID: [32]byte{1}, Side: 0, Price: 1, Size: 1}
b2, err := json.Marshal(atFloor)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if err := l2.SubmitTx(b2); err != nil {
t.Fatalf("SubmitTx(at-floor on L2) = %v, want nil", err)
}
}
// TestSwapNativeFeeToLUX_Deterministic exercises the swap-to-LUX settle path: a
// fee tendered in the local native asset is converted to LUX at a confirmed
// matcher fill price, and the LUX floor is enforced on the OUTPUT (LUX is the
// canonical sink). The conversion is deterministic — identical inputs always
// yield identical LUX — and an under-floor swap output is refused exactly like an
// under-floor LUX tender at the mempool gate.
func TestSwapNativeFeeToLUX_Deterministic(t *testing.T) {
cvm := newChainVMForNetwork(t, luxL2NetworkID)
// 1:1 parity — a native fee at the floor settles to exactly the floor in LUX.
if got, err := swapNativeFeeToLUX(fee.MinTxFeeFloor, fp(1.0)); err != nil || got != fee.MinTxFeeFloor {
t.Fatalf("swapNativeFeeToLUX(floor, fp(1.0)) = (%d, %v), want (%d, nil)", got, err, fee.MinTxFeeFloor)
}
// Determinism: the same native fee + fill price always yields the same LUX.
const nativeFee = uint64(20_000_000)
price := fp(0.1) // LUX per native unit, fixed-point ×PriceScale
first, err := swapNativeFeeToLUX(nativeFee, price)
if err != nil {
t.Fatalf("swap: %v", err)
}
for i := 0; i < 1000; i++ {
got, err := swapNativeFeeToLUX(nativeFee, price)
if err != nil || got != first {
t.Fatalf("non-deterministic swap on iter %d: got (%d,%v), want (%d,nil)", i, got, err, first)
}
}
if first != 2_000_000 { // floor(20_000_000 * 0.1) = 2_000_000 LUX
t.Fatalf("swap(20e6, fp(0.1)) = %d, want 2_000_000", first)
}
// settleFeeInLUX enforces the LUX floor on the swapped output. 2_000_000 >= floor.
if got, err := cvm.settleFeeInLUX(nativeFee, price); err != nil || got != 2_000_000 {
t.Fatalf("settleFeeInLUX(20e6, fp(0.1)) = (%d, %v), want (2_000_000, nil)", got, err)
}
// An under-floor swap output is refused (native fee too small for the price):
// floor(1_000_000 * 0.1) = 100_000 LUX < 1_000_000 floor.
if _, err := cvm.settleFeeInLUX(1_000_000, fp(0.1)); !errors.Is(err, fee.ErrInsufficientFee) {
t.Fatalf("settleFeeInLUX(under-floor output) = %v, want ErrInsufficientFee", err)
}
// Proceeds floor DOWN (never credit LUX the fill did not realize, floor-safe):
// floor(3 * 0.5) = floor(1.5) = 1 LUX.
if got, err := swapNativeFeeToLUX(3, fp(0.5)); err != nil || got != 1 {
t.Fatalf("swapNativeFeeToLUX(3, fp(0.5)) = (%d, %v), want (1, nil) [floor-safe round down]", got, err)
}
// A zero fill price is refused (no silent zero/garbage settlement).
if _, err := swapNativeFeeToLUX(fee.MinTxFeeFloor, 0); err == nil {
t.Fatalf("swapNativeFeeToLUX with zero price = nil err, want refusal")
}
}
-102
View File
@@ -1,102 +0,0 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dexvm
import (
"context"
"encoding/json"
"errors"
"testing"
"github.com/luxfi/chains/dexvm/txs"
"github.com/luxfi/database/memdb"
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/node/vms/types/fee"
"github.com/luxfi/runtime"
"github.com/luxfi/vm"
"github.com/luxfi/warp"
)
// newChainVMForFeeTest returns a fully-initialized ChainVM wired with the
// canonical D-Chain FlatPolicy. networkID=96369 (mainnet) so the asset
// is the legacy UTXO_ASSET_ID, the same on-chain literal mainnet pays.
func newChainVMForFeeTest(t *testing.T) *ChainVM {
t.Helper()
logger := log.NewNoOpLogger()
cvm := NewChainVM(logger)
rt := &runtime.Runtime{
ChainID: ids.GenerateTestID(),
NetworkID: 96369,
Log: logger,
}
if err := cvm.Initialize(context.Background(), vm.Init{
Runtime: rt,
DB: memdb.New(),
ToEngine: make(chan vm.Message, 8),
Sender: warp.FakeSender{},
Log: logger,
}); err != nil {
t.Fatalf("init dexvm: %v", err)
}
return cvm
}
func TestDexVM_FeePolicy_AttachedAtInit(t *testing.T) {
cvm := newChainVMForFeeTest(t)
if cvm.FeePolicy() == nil {
t.Fatal("FeePolicy() = nil after Initialize; want non-nil FlatPolicy")
}
if got := cvm.FeePolicy().MinTxFee(); got != fee.MinTxFeeFloor {
t.Errorf("MinTxFee() = %d, want %d", got, fee.MinTxFeeFloor)
}
if err := fee.Validate(cvm.FeePolicy()); err != nil {
t.Errorf("fee.Validate = %v, want nil", err)
}
}
func TestDexVM_FeePolicy_RejectsZeroFee(t *testing.T) {
cvm := newChainVMForFeeTest(t)
tx := &txs.PlaceOrderTx{BaseTx: txs.BaseTx{
TxType: txs.TxPlaceOrder,
From: ids.GenerateTestShortID(),
Nonce: 1,
GasPrice: 0, // <-- the bug we are closing
GasLimit: 0,
}, PoolID: [32]byte{1}, Side: 0, Price: 1, Size: 1}
b, err := json.Marshal(tx)
if err != nil {
t.Fatalf("marshal: %v", err)
}
err = cvm.SubmitTx(b)
if !errors.Is(err, fee.ErrInsufficientFee) {
t.Fatalf("SubmitTx(zero-fee) = %v, want ErrInsufficientFee", err)
}
}
func TestDexVM_FeePolicy_AcceptsMinFee(t *testing.T) {
cvm := newChainVMForFeeTest(t)
// GasPrice * GasLimit = MinTxFeeFloor (1_000_000 nLUX = 1 mLUX).
tx := &txs.PlaceOrderTx{BaseTx: txs.BaseTx{
TxType: txs.TxPlaceOrder,
From: ids.GenerateTestShortID(),
Nonce: 1,
GasPrice: 1_000,
GasLimit: 1_000,
}, PoolID: [32]byte{1}, Side: 0, Price: 1, Size: 1}
b, err := json.Marshal(tx)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if err := cvm.SubmitTx(b); err != nil {
t.Fatalf("SubmitTx(min-fee) = %v, want nil", err)
}
}
func TestDexVM_FeePolicy_RejectsUndecodable(t *testing.T) {
cvm := newChainVMForFeeTest(t)
if err := cvm.SubmitTx([]byte("not json")); err == nil {
t.Fatal("SubmitTx(garbage) = nil, want decode error")
}
}
-118
View File
@@ -1,118 +0,0 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dexvm
import (
"errors"
"github.com/luxfi/crypto/ed25519"
"github.com/luxfi/ids"
"github.com/zeebo/blake3"
)
// fill_attestation.go closes the MEDIUM single-proposer carried-fills trust gap: the
// d-chain MATCHER (the venue) ATTESTS the fills it returned with an Ed25519 signature
// over a canonical message; every validator verifies that signature before settling
// from the carried fills. Without it, a malicious or MITM proposer can carry FABRICATED
// fills (carried_fills.go documents this as the interim, conservation-bounded trust
// surface — a lying proposer is bounded to ONE taker's own escrow, never supply
// inflation, but it CAN mis-settle that taker). With it, the proceeds a validator
// settles are ATTESTED by the venue, not merely proposer-asserted.
//
// THE RESERVED SLOT. The carried-fills wire (carried_fills.go encode/decodeCarriedFills)
// already reserves a self-delimiting `sig` field. This module fills it: the signature
// rides there with NO further wire-format change (the reserved-but-unimplemented slot
// noted at vm.go BuildBlockResult). The trustless evolution path (P3Q -> starkfri ZK
// proof of the matching) can later replace the Ed25519 verify here behind the SAME slot.
//
// SCHEME. Ed25519 (luxfi/crypto) over blake3(domain || blockHash || canonical-entries).
// The message is a DETERMINISTIC function of the block hash + the carried entries, so
// every validator recomputes the identical message and a single venue signature covers
// the whole block's fills. The message binds the BLOCK HASH so a venue signature for one
// block's fills cannot be replayed onto another block (a different block hash => a
// different message => the signature fails).
//
// ENFORCEMENT GATE (config.RequireFillAttestation, derived: ON iff a venue pubkey is
// configured). For an UNTRUSTED validator set the operator configures the venue's
// attestation pubkey; enforcement is then ON and a block whose carried fills lack a
// valid attestation is settled as a FULL REFUND (zero-fill) — fail-secure: an
// unattested/forged fill never moves proceeds. For a single-trusted-operator dev
// network no pubkey is configured; enforcement is OFF and the carried fills settle as
// before (the documented interim model). Default-on the moment a pubkey exists.
// fillAttestationDomain scopes the attestation message so a venue signature over a
// carried-fills block can never be confused with any other Ed25519 message the venue
// key might sign.
const fillAttestationDomain = "lux.dex.dexvm.fills.attestation.v1"
var (
// ErrFillAttestationRequired is returned when enforcement is on (a venue pubkey is
// configured) but the block carried NO attestation signature.
ErrFillAttestationRequired = errors.New("dexvm: fill attestation required but the block carried no signature")
// ErrFillAttestationInvalid is returned when the carried signature does not verify
// against the configured venue pubkey over the canonical (blockHash, entries) message.
ErrFillAttestationInvalid = errors.New("dexvm: carried fill attestation signature is invalid")
)
// fillAttestationMessage builds the canonical, deterministic message a venue signs to
// attest the fills it returned for a block: blake3(domain || blockHash || entries),
// where the entries are serialized in their canonical block-order form (the SAME
// length-prefixed encoding the block carries, MINUS the reserved sig — a signature can
// obviously not cover itself). Every validator recomputes this identically, so one
// venue signature covers the whole block's carried fills.
func fillAttestationMessage(blockHash ids.ID, entries []carriedFill) []byte {
// encodeCarriedFills(entries, nil) is the canonical entries serialization with an
// empty sig tail — a deterministic function of (txIndex, fills) in block order.
body := encodeCarriedFills(entries, nil)
h := blake3.New()
_, _ = h.Write([]byte(fillAttestationDomain))
_, _ = h.Write(blockHash[:])
_, _ = h.Write(body)
var sum [32]byte
h.Digest().Read(sum[:])
return sum[:]
}
// signFillAttestation produces the venue's Ed25519 attestation over the canonical
// (blockHash, entries) message. The proposer uses this ONLY when it holds the venue
// signing key (a co-located single-operator venue); otherwise the venue returns the
// signature alongside its fills and the proposer carries it verbatim. A nil/empty key
// yields a nil signature (no attestation produced).
func signFillAttestation(signer ed25519.PrivateKey, blockHash ids.ID, entries []carriedFill) []byte {
if len(signer) != ed25519.PrivateKeySize {
return nil
}
return ed25519.Sign(signer, fillAttestationMessage(blockHash, entries))
}
// verifyFillAttestation checks the carried signature against the configured venue
// pubkey over the canonical (blockHash, entries) message, applying the enforcement
// gate:
//
// - pubKey empty (no venue key configured: single-trusted-operator / dev): enforcement
// is OFF; returns nil regardless of sig (the documented interim trust model).
// - pubKey set (untrusted validator set): enforcement is ON; a missing signature is
// ErrFillAttestationRequired and an invalid one is ErrFillAttestationInvalid. Only a
// signature that verifies against the venue pubkey permits the carried fills to
// settle — so a fabricated fill with no/invalid attestation is refused.
func verifyFillAttestation(pubKey []byte, sig []byte, blockHash ids.ID, entries []carriedFill) error {
if len(pubKey) == 0 {
return nil // enforcement off (no venue attestation key configured)
}
if len(pubKey) != ed25519.PublicKeySize {
// A misconfigured (wrong-width) pubkey must FAIL CLOSED — never silently treat a
// garbage key as "no enforcement", which would re-open the proposer-trust gap.
return ErrFillAttestationInvalid
}
if len(sig) == 0 {
return ErrFillAttestationRequired
}
if len(sig) != ed25519.SignatureSize {
return ErrFillAttestationInvalid
}
if !ed25519.Verify(ed25519.PublicKey(pubKey), fillAttestationMessage(blockHash, entries), sig) {
return ErrFillAttestationInvalid
}
return nil
}
-150
View File
@@ -1,150 +0,0 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dexvm
import (
"bytes"
"testing"
"github.com/luxfi/dex/pkg/zapwire"
"github.com/luxfi/ids"
"github.com/luxfi/chains/dexvm/txs"
)
// frame_parity_test.go is the CROSS-MODULE oracle: it pins the proxy's exact-integer
// ZAP frames BYTE-IDENTICAL to dex v1.14.0's frozen zapwire codec (the venue's own
// source of truth). An interop mismatch is the whole failure mode of the wire change
// — the proxy must produce/consume the SAME bytes the d-chain gateway speaks — so we
// assert it against the canonical package directly, not a re-declared copy.
//
// zapwire is a PURE-GO stdlib leaf (no cgo/GPU matcher), so the proxy can import it
// in a test even under CGO_ENABLED=0. The production proxy re-declares the frozen
// constants (relay.go / vm.go) to stay a clean leaf over luxfi/rpc; this test is the
// guard that the re-declaration never drifts from the canonical frame.
// TestFrameParity_Constants pins the wire constants to zapwire's.
func TestFrameParity_Constants(t *testing.T) {
if PriceScale != zapwire.PriceScale {
t.Fatalf("PriceScale %d != zapwire.PriceScale %d — the fixed-point grid diverged", PriceScale, zapwire.PriceScale)
}
if FillWireSize != zapwire.FillWireSize {
t.Fatalf("FillWireSize %d != zapwire.FillWireSize %d — the fill frame diverged", FillWireSize, zapwire.FillWireSize)
}
if ZAPMethodSubmit == "" || ZAPMethodPlace == "" {
t.Fatalf("empty ZAP method names")
}
}
// TestFrameParity_PlaceFrame proves encodeCLOBPlace emits the EXACT bytes
// zapwire.EncodePlace does for the same order, and that the venue's DecodePlace reads
// back the exact-integer fixed-point price and base-unit size.
func TestFrameParity_PlaceFrame(t *testing.T) {
var pool [32]byte
for i := range pool {
pool[i] = byte(i + 1)
}
const humanPrice = uint64(10)
const size = uint64(5)
from := ids.ShortID{0xAB, 0xCD, 0xEF, 0x01, 0x02}
tx := txs.NewPlaceOrderTx(from, 0, pool, zapwire.SideSell, humanPrice, size)
maker := tx.Sender()
got := encodeCLOBPlace(tx)
// The venue reads the price field as the fixed-point ×PriceScale PriceInt grid
// value, so the proxy scales the whole human price onto it: humanPrice*PriceScale.
want := zapwire.EncodePlace(pool, zapwire.SideSell, humanPrice*PriceScale, size, string(maker[:16]))
if !bytes.Equal(got, want) {
t.Fatalf("place frame != zapwire.EncodePlace\n got=%x\nwant=%x", got, want)
}
// The venue's own decoder reads back exact integers (no float on the wire).
dPool, dSide, dPrice, dSize, _, err := zapwire.DecodePlace(got)
if err != nil {
t.Fatalf("zapwire.DecodePlace(proxy frame): %v", err)
}
if dPool != pool || dSide != zapwire.SideSell {
t.Fatalf("decoded pool/side mismatch: side=%d", dSide)
}
if dPrice != humanPrice*PriceScale {
t.Fatalf("decoded price=%d, want %d (fixed-point ×PriceScale)", dPrice, humanPrice*PriceScale)
}
if dSize != size {
t.Fatalf("decoded size=%d, want %d (atomic base units)", dSize, size)
}
}
// TestFrameParity_SubmitFrame proves the 66-byte submit frame the proxy's test
// harness builds (clobSubmitPayload) is byte-identical to zapwire.EncodeSubmit for a
// market order — the exact-integer frame the 0x9999 EVM surface and the native venue
// both speak.
func TestFrameParity_SubmitFrame(t *testing.T) {
var pool [32]byte
for i := range pool {
pool[i] = byte(0x40 + i)
}
const size = uint64(1234)
got := clobSubmitPayload(ids.ID(pool), size)
// clobSubmitPayload builds a market BUY with limit 0 and empty user, size in base
// units — the same fields, byte-for-byte, as the venue's EncodeSubmit.
want := zapwire.EncodeSubmit(pool, zapwire.SideBuy, true, 0, size, "")
if !bytes.Equal(got, want) {
t.Fatalf("submit frame != zapwire.EncodeSubmit\n got=%x\nwant=%x", got, want)
}
_, dSide, dMarket, dLimit, dSize, _, err := zapwire.DecodeSubmit(got)
if err != nil {
t.Fatalf("zapwire.DecodeSubmit(proxy frame): %v", err)
}
if dSide != zapwire.SideBuy || !dMarket || dLimit != 0 || dSize != size {
t.Fatalf("decoded submit mismatch: side=%d market=%v limit=%d size=%d", dSide, dMarket, dLimit, dSize)
}
}
// TestFrameParity_FillWire proves the fill response wire round-trips byte-identically
// between the proxy's DecodeFills and the venue's zapwire.EncodeFills/DecodeFills:
// price is fixed-point ×PriceScale, size is atomic base units, both EXACT uint64s.
func TestFrameParity_FillWire(t *testing.T) {
// A stream with a fractional-flooring price to exercise the fixed-point grid.
proxyFills := []Fill{
{Price: fp(2), Size: 100, Side: zapwire.SideBuy},
{Price: fp(1.5), Size: 4, Side: zapwire.SideSell},
}
// (a) proxy-encoded wire decodes identically under the venue's codec.
wire := encodeFillsWire(proxyFills)
venueFills, err := zapwire.DecodeFills(wire)
if err != nil {
t.Fatalf("zapwire.DecodeFills(proxy wire): %v", err)
}
if len(venueFills) != len(proxyFills) {
t.Fatalf("fill count %d != %d", len(venueFills), len(proxyFills))
}
for i, f := range proxyFills {
if venueFills[i].Price != f.Price || venueFills[i].Size != f.Size || venueFills[i].TakerSide != f.Side {
t.Fatalf("fill %d mismatch: venue=%+v proxy=%+v", i, venueFills[i], f)
}
}
// (b) venue-encoded wire decodes identically under the proxy's DecodeFills — the
// real direction: the venue returns fills, the proxy consumes them.
venueWire := zapwire.EncodeFills([]zapwire.Fill{
{Price: fp(2), Size: 100, TakerSide: zapwire.SideBuy},
{Price: fp(1.5), Size: 4, TakerSide: zapwire.SideSell},
})
if !bytes.Equal(venueWire, wire) {
t.Fatalf("fill wire not byte-identical\n proxy=%x\n venue=%x", wire, venueWire)
}
back, err := DecodeFills(venueWire)
if err != nil {
t.Fatalf("proxy DecodeFills(venue wire): %v", err)
}
for i, f := range proxyFills {
if back[i].Price != f.Price || back[i].Size != f.Size || back[i].Side != f.Side {
t.Fatalf("proxy round-trip fill %d mismatch: %+v vs %+v", i, back[i], f)
}
}
}
-180
View File
@@ -1,180 +0,0 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dexvm
import (
"context"
"testing"
"github.com/luxfi/ids"
"github.com/luxfi/chains/dexvm/txs"
)
// native_lp_seam_test.go proves the D side of the LP D-COMMITTED LIQUIDITY rail
// (precompile/dex 0x9999 modifyLiquidity / collectPosition) consumes/produces the
// EXACT atomic wire the C side reads/writes — WITHOUT any new D-side primitive. A
// position-commit object (DL01) is byte-identical to any cross-chain value object
// (owner|asset|amount), so:
//
// - C->D COMMIT leg == executeDeposit (atomic import -> credit the position's
// collateral into the D-Chain CLOB ledger). D's position (range + fee accrual)
// lives in the CLOB, FUNDED by consuming the C->D commit object exactly once.
// - D->C COLLECT leg == executeWithdraw (debit the LP's realized D-Chain balance —
// principal + accrued fees the CLOB credited the maker — and atomically export it
// back to C). C is credited ONLY by consuming this D->C object.
//
// These tests drive the REAL executors on LP-shaped value, the same way
// native_seam_roundtrip_test.go does for the swap rail, closing the LP round trip
// across the genuine atomic core. The ship rule holds: D position funded ONLY by a
// consumed C->D object; C credited ONLY by a consumed D->C object.
// TestNativeLP_CommitImportsFundsDPosition drives the REAL executeDeposit on a
// position-commit-shaped (DL01) C->D object: it consumes the object exactly once and
// credits the LP's collateral into the D-Chain ledger — the native funding of a D
// position. A position is funded ONLY by consuming this object.
func TestNativeLP_CommitImportsFundsDPosition(t *testing.T) {
h := newCustodyHarness(t)
ctx := context.Background()
lp := ids.GenerateTestShortID()
token := ids.GenerateTestID() // the LP's committed asset (e.g. currency0)
const principal = 1000
// The precompile's SubmitPositionCommit wrote a railLP C->D commit object (rail|
// owner|asset|amount) into shared memory; model it with fundCChainRail (proven
// byte-identical to the precompile wire in TestNativeSeam_WireMatchesPrecompile).
commitUTXO := h.fundCChainRail(t, txs.RailLP, lp, token, principal)
// REAL executeDeposit: atomic import (consume-once) + credit the D-Chain ledger
// with exactly the committed value — the LP's position collateral is now funded on
// D. The CLOB opens/rests the position against this collateral (the matcher's job).
arDep := newAtomicRequests()
commitTx := txs.NewImportTx(lp, 0, h.cChain,
[]txs.AtomicInput{{UTXOID: commitUTXO, Asset: token, Amount: principal}},
[]txs.AtomicOutput{{Rail: txs.RailLP, Owner: lp, Asset: token, Amount: principal}})
if err := h.vm.executeDeposit(ctx, commitTx, arDep); err != nil {
t.Fatalf("executeDeposit of DL01 commit object: %v", err)
}
// The commit object was consumed exactly once (atomic Remove against the source).
req, ok := arDep.reqs[h.cChain]
if !ok || len(req.RemoveRequests) != 1 {
t.Fatalf("commit import must accumulate exactly one atomic Remove of the consumed C->D object")
}
// The D-Chain ledger now holds the LP's committed collateral (the matcher credits
// it). The ledger is the position's funding; without this consumed object there is
// no funding — the ship rule's C->D leg.
if got := h.ledger.bal[ledgerKey(frameUser(lp), token)]; got != principal {
t.Fatalf("D-Chain ledger must hold the committed principal %d, got %d", principal, got)
}
// REPLAY: a second import of the same commit object is rejected (consume-once), so
// a re-executed/reorged commit cannot double-fund the position.
arReplay := newAtomicRequests()
if err := h.vm.executeImport(commitTx, arReplay); err == nil {
t.Fatal("re-importing the same DL01 commit object must be rejected (consume-once)")
}
}
// TestNativeLP_CollectExportsPrincipalPlusFees drives the REAL executeWithdraw on the
// LP's realized D-Chain balance (principal + accrued fees the CLOB credited the
// maker): it debits the ledger and atomically EXPORTS a D->C object the precompile's
// collectPosition consumes to credit C. C is credited ONLY by consuming this object.
func TestNativeLP_CollectExportsPrincipalPlusFees(t *testing.T) {
h := newCustodyHarness(t)
ctx := context.Background()
lp := ids.GenerateTestShortID()
token := ids.GenerateTestID()
const principal = 1000
const fees = 80
const withdrawable = principal + fees // what the CLOB credited the maker
// Fund + commit the principal (the C->D leg), then model the CLOB crediting the
// maker `fees` more from taker flow (the ledger now owes the LP principal+fees).
commitUTXO := h.fundCChainRail(t, txs.RailLP, lp, token, principal)
arDep := newAtomicRequests()
commitTx := txs.NewImportTx(lp, 0, h.cChain,
[]txs.AtomicInput{{UTXOID: commitUTXO, Asset: token, Amount: principal}},
[]txs.AtomicOutput{{Rail: txs.RailLP, Owner: lp, Asset: token, Amount: principal}})
if err := h.vm.executeDeposit(ctx, commitTx, arDep); err != nil {
t.Fatalf("commit deposit: %v", err)
}
h.ledger.bal[ledgerKey(frameUser(lp), token)] += fees // CLOB credits the maker its earned fees.
// COLLECT: REAL executeWithdraw debits the LP's realized balance (clamped to
// availability) and exports EXACTLY that back to C. The realized debit is the
// ledger's, never more (no mint).
arWd := newAtomicRequests()
realized, err := h.vm.executeWithdraw(ctx, lp, token, withdrawable, h.cChain, ids.GenerateTestID(), 0, 12345, arWd)
if err != nil {
t.Fatalf("executeWithdraw (collect): %v", err)
}
if realized != withdrawable {
t.Fatalf("collect must realize principal+fees = %d, got %d", withdrawable, realized)
}
// Exactly one D->C object was exported, decoding under the precompile wire to
// (lp, token, withdrawable) — the precompile's collectPosition would credit the LP
// exactly this. C is credited ONLY by consuming this object.
req, ok := arWd.reqs[h.cChain]
if !ok || len(req.PutRequests) != 1 {
t.Fatalf("collect export must produce exactly one D->C object")
}
r, o, a, amt, _, decOK := decodeExportedOutput(req.PutRequests[0].Value)
if !decOK || r != txs.RailLP || o != lp || a != token || amt != withdrawable {
t.Fatalf("D->C collect object mismatch: ok=%v rail=%d owner=%v asset=%v amount=%d", decOK, r, o, a, amt)
}
// The ledger was fully drained for this asset (principal+fees withdrawn).
if got := h.ledger.bal[ledgerKey(frameUser(lp), token)]; got != 0 {
t.Fatalf("D-Chain ledger must be 0 after the full collect, got %d", got)
}
}
// TestNativeLP_CommitCollectRoundTripConserves drives BOTH real executors end-to-end:
// commit (C->D import funds the position) -> earn fees on D -> collect (D->C export
// returns principal+fees), asserting the D side conserves — the exported value equals
// the committed principal plus the ledger-credited fees, sourced only from consumed
// objects and ledger credits, never minted.
func TestNativeLP_CommitCollectRoundTripConserves(t *testing.T) {
h := newCustodyHarness(t)
ctx := context.Background()
lp := ids.GenerateTestShortID()
token := ids.GenerateTestID()
const principal = 1500
const fees = 120
// --- C->D commit (position funded). ---
commitUTXO := h.fundCChainRail(t, txs.RailLP, lp, token, principal)
arDep := newAtomicRequests()
commitTx := txs.NewImportTx(lp, 0, h.cChain,
[]txs.AtomicInput{{UTXOID: commitUTXO, Asset: token, Amount: principal}},
[]txs.AtomicOutput{{Rail: txs.RailLP, Owner: lp, Asset: token, Amount: principal}})
if err := h.vm.executeDeposit(ctx, commitTx, arDep); err != nil {
t.Fatalf("commit: %v", err)
}
if len(arDep.reqs[h.cChain].RemoveRequests) != 1 {
t.Fatal("commit must consume the C->D object exactly once")
}
// --- D earns fees for the LP (modeled CLOB maker credit). ---
h.ledger.bal[ledgerKey(frameUser(lp), token)] += fees
// --- D->C collect (principal + fees returned). ---
arWd := newAtomicRequests()
realized, err := h.vm.executeWithdraw(ctx, lp, token, principal+fees, h.cChain, ids.GenerateTestID(), 0, 999, arWd)
if err != nil {
t.Fatalf("collect: %v", err)
}
// CONSERVATION: the LP committed `principal` (consumed C->D object) and the CLOB
// credited `fees`; the collect exports exactly principal+fees — value moves only
// via the consumed object + the ledger credit, never minted on the proxy.
if realized != principal+fees {
t.Fatalf("round-trip realized %d != committed+fees %d", realized, principal+fees)
}
r, o, a, amt, _, ok := decodeExportedOutput(arWd.reqs[h.cChain].PutRequests[0].Value)
if !ok || r != txs.RailLP || o != lp || a != token || amt != principal+fees {
t.Fatalf("round-trip D->C object mismatch: ok=%v rail=%d owner=%v asset=%v amount=%d", ok, r, o, a, amt)
}
}
-88
View File
@@ -1,88 +0,0 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dexvm
import (
"testing"
"github.com/luxfi/ids"
"github.com/luxfi/chains/dexvm/txs"
)
// native_rail_bind_test.go proves the D side of the H1 rail bind (defense in depth,
// symmetric to the precompile's C-side gates): executeImport binds the credited
// outputs' RAIL to the consumed UTXO's RECORDED rail, so an import cannot re-lane
// value — credit a railSwap object onto the LP lane or a railLP object onto the swap
// lane. The recorded rail (written by the export side via encodeExportedOutput) is
// authoritative; the importing tx cannot freely choose it.
// TestNativeRail_ImportRejectsRailMismatch — an import whose declared output rail
// disagrees with the consumed UTXO's RECORDED rail is REJECTED (errImportMixedRails),
// so a cross-rail object cannot be re-laned at the import. Proven on both directions
// (railSwap object claimed as LP, railLP object claimed as swap).
func TestNativeRail_ImportRejectsRailMismatch(t *testing.T) {
cases := []struct {
name string
recorded txs.Rail // the rail written into the source UTXO
claimed txs.Rail // the rail the importing tx's output declares
}{
{"swap-object-claimed-as-LP", txs.RailSwap, txs.RailLP},
{"LP-object-claimed-as-swap", txs.RailLP, txs.RailSwap},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
h := newCustodyHarness(t)
owner := ids.GenerateTestShortID()
token := ids.GenerateTestID()
const amount = 1000
// The source UTXO records `recorded` rail.
utxo := h.fundCChainRail(t, tc.recorded, owner, token, amount)
// The importing tx declares the MISMATCHED `claimed` rail on its output.
ar := newAtomicRequests()
tx := txs.NewImportTx(owner, 0, h.cChain,
[]txs.AtomicInput{{UTXOID: utxo, Asset: token, Amount: amount}},
[]txs.AtomicOutput{{Rail: tc.claimed, Owner: owner, Asset: token, Amount: amount}})
err := h.vm.executeImport(tx, ar)
if err == nil {
t.Fatal("a cross-rail import (output rail != recorded UTXO rail) MUST be rejected")
}
// The consumed-set must be UNTOUCHED on a rejected import (all-or-nothing) —
// the rightful owner can still claim the UTXO on its correct rail.
consumed, cerr := h.vm.state.IsConsumed(utxo)
if cerr != nil {
t.Fatalf("IsConsumed: %v", cerr)
}
if consumed {
t.Fatal("a rejected cross-rail import must NOT burn the consumed UTXO")
}
})
}
}
// TestNativeRail_ImportAcceptsMatchingRail — the positive control: an import whose
// output rail MATCHES the recorded UTXO rail succeeds on BOTH lanes, so the rail bind
// is a precise match, not a blanket refusal.
func TestNativeRail_ImportAcceptsMatchingRail(t *testing.T) {
for _, rail := range []txs.Rail{txs.RailSwap, txs.RailLP} {
h := newCustodyHarness(t)
owner := ids.GenerateTestShortID()
token := ids.GenerateTestID()
const amount = 1000
utxo := h.fundCChainRail(t, rail, owner, token, amount)
ar := newAtomicRequests()
tx := txs.NewImportTx(owner, 0, h.cChain,
[]txs.AtomicInput{{UTXOID: utxo, Asset: token, Amount: amount}},
[]txs.AtomicOutput{{Rail: rail, Owner: owner, Asset: token, Amount: amount}})
if err := h.vm.executeImport(tx, ar); err != nil {
t.Fatalf("rail %d: a matching-rail import must succeed, got: %v", rail, err)
}
if len(ar.reqs[h.cChain].RemoveRequests) != 1 {
t.Fatalf("rail %d: a matching-rail import must consume the UTXO exactly once", rail)
}
}
}
-104
View File
@@ -1,104 +0,0 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dexvm
import (
"encoding/hex"
"testing"
"github.com/luxfi/chains/dexvm/txs"
"github.com/luxfi/ids"
)
// native_seam_parity_test.go pins the C<->D atomic object wire BYTE-FOR-BYTE on the dexvm
// (D) side. It is the twin of the precompile's native_seam_parity_test.go: BOTH repos encode
// the SAME shared-memory UTXO value — rail(1) | owner(20) | asset(32) | amount(8) | spent(8)
// = 69 bytes — and a one-sided change is a silent consensus break (D exports a width C cannot
// decode -> every swap settlement reverts and no taker is credited). The golden vector below
// is IDENTICAL to the precompile's parityGoldenHex; if either repo's layout drifts, one of the
// two parity tests fails before the bytes reach consensus. The trailing spent(8) is the
// matched-input witness the C-side ImportSettlement reads to enforce the taker-authenticated
// MEV floor; it MUST round-trip on this side too.
const parityGoldenHex = "00" + // rail (RailSwap)
"112233445566778899aabbccddeeff0102030405" + // owner (20)
"a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf" + // asset (32)
"0102030405060708" + // amount (8, big-endian)
"1112131415161718" // spent (8, big-endian)
func parityOwner() ids.ShortID {
return ids.ShortID{
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa,
0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x01, 0x02, 0x03, 0x04, 0x05,
}
}
func parityAsset() ids.ID {
var a ids.ID
for i := range a {
a[i] = byte(0xA0 + i)
}
return a
}
// TestSeamWire_GoldenMatchesPrecompile pins the dexvm encoder against the cross-repo golden:
// encodeExportedOutput must produce the EXACT bytes the precompile's encodeAtomicObjectSpent
// does for the same inputs (lockstep), at the canonical 69-byte width.
func TestSeamWire_GoldenMatchesPrecompile(t *testing.T) {
want, err := hex.DecodeString(parityGoldenHex)
if err != nil {
t.Fatalf("bad golden hex: %v", err)
}
if len(want) != exportedOutputSize {
t.Fatalf("golden width %d != exportedOutputSize %d — the wire changed on ONE side only "+
"(consensus break). Update BOTH repos in lockstep.", len(want), exportedOutputSize)
}
out := txs.AtomicOutput{
Rail: txs.RailSwap,
Owner: parityOwner(),
Asset: parityAsset(),
Amount: 0x0102030405060708,
Spent: 0x1112131415161718,
}
got := encodeExportedOutput(out)
if hex.EncodeToString(got) != parityGoldenHex {
t.Fatalf("dexvm seam wire DIVERGED from the precompile golden:\n got=%s\nwant=%s\n"+
"the C<->D atomic object is no longer byte-identical — every swap settlement would fail "+
"to decode on C. Re-align atomic.go with precompile/dex/native_wire.go.",
hex.EncodeToString(got), parityGoldenHex)
}
}
// TestSeamWire_RoundTripCarriesSpent pins that decode is the exact inverse of encode and that
// the trailing spent witness survives — the value the MEV floor depends on.
func TestSeamWire_RoundTripCarriesSpent(t *testing.T) {
out := txs.AtomicOutput{
Rail: txs.RailSwap,
Owner: parityOwner(),
Asset: parityAsset(),
Amount: 9_000_000,
Spent: 4_500_000,
}
enc := encodeExportedOutput(out)
rail, owner, asset, amount, spent, ok := decodeExportedOutput(enc)
if !ok {
t.Fatal("decode of a canonical 69-byte object must succeed")
}
if rail != out.Rail || owner != out.Owner || asset != out.Asset || amount != out.Amount {
t.Fatalf("round-trip mismatch: rail=%d owner=%x asset=%x amount=%d", rail, owner, asset, amount)
}
if spent != out.Spent {
t.Fatalf("round-trip SPENT = %d, want %d — the matched-input witness was lost (the C-side MEV "+
"floor would silently read 0 and never engage).", spent, out.Spent)
}
}
// TestSeamWire_WrongWidthRejected pins that a non-canonical width never decodes (the corrupt-
// record defense; a stale 61-byte object or any other width is refused, never read as value).
func TestSeamWire_WrongWidthRejected(t *testing.T) {
for _, n := range []int{0, 60, 61, 68, 70, 100} {
if _, _, _, _, _, ok := decodeExportedOutput(make([]byte, n)); ok {
t.Fatalf("a %d-byte object must NOT decode (only the canonical %d is valid)", n, exportedOutputSize)
}
}
}
-214
View File
@@ -1,214 +0,0 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dexvm
import (
"context"
"encoding/binary"
"testing"
"github.com/luxfi/ids"
"github.com/luxfi/chains/dexvm/txs"
)
// native_seam_roundtrip_test.go proves the D side of the native C<->D atomic
// settlement seam (precompile/dex 0x9999) consumes/produces the EXACT wire the C
// side reads/writes. The precompile encodes a cross-chain object as
// owner(20)|asset(32)|amount(8) (precompile/dex/native_wire.go encodeAtomicObject),
// byte-identical to this VM's encodeExportedOutput; this test:
//
// 1. asserts that byte-identity (the contract between the two repos), and
// 2. drives the REAL executeImport (C->D leg) on a precompile-shaped object and
// the REAL executeExport (D->C leg) producing an object the precompile decodes.
//
// Together with the precompile-side Test9999RoundTrip_CToDMatchDToC (which drives
// the real precompile client over a real atomic.SharedMemory), this closes the
// round trip: C creates a C->D object -> D imports+matches -> D exports a D->C
// object -> C imports+credits, with one wire format on both ends.
// encodeAtomicObjectC replicates precompile/dex.encodeAtomicObject byte-for-byte:
// rail(1) | owner(20) | asset(32) | amount(8) | spent(8). (The chains repo cannot import
// the precompile — it is a leaf library, and importing it would invert the dependency —
// so the 69-byte contract is replicated here and asserted byte-identical to the VM's own
// encodeExportedOutput below.) The trailing spent(8) carries the matched input on a swap
// proceeds leg so the C-side can enforce the taker's recorded slippage limit; both repos
// extend it in lockstep and this canary pins the joint width + layout.
func encodeAtomicObjectC(rail txs.Rail, owner ids.ShortID, asset ids.ID, amount, spent uint64) []byte {
v := make([]byte, 1+20+32+8+8)
v[0] = byte(rail)
copy(v[1:21], owner[:])
copy(v[21:53], asset[:])
binary.BigEndian.PutUint64(v[53:61], amount)
binary.BigEndian.PutUint64(v[61:69], spent)
return v
}
// TestNativeSeam_WireMatchesPrecompile asserts the precompile's C<->D object wire
// is byte-identical to this VM's encodeExportedOutput, so an object written by one
// side is consumed natively by the other. A drift here would silently break the
// seam (the import would read a misaligned asset/amount), so this is the canary.
func TestNativeSeam_WireMatchesPrecompile(t *testing.T) {
owner := ids.GenerateTestShortID()
asset := ids.GenerateTestID()
const amount = 123456789
// Assert byte-identity on BOTH rails AND with/without a spent witness — the rail byte
// (object[0]) and the trailing spent(8) must round-trip identically through the VM's
// encode and the precompile-replicated encode.
for _, rail := range []txs.Rail{txs.RailSwap, txs.RailLP} {
for _, spent := range []uint64{0, 987654321} {
fromVM := encodeExportedOutput(txs.AtomicOutput{Rail: rail, Owner: owner, Asset: asset, Amount: amount, Spent: spent})
fromPrecompile := encodeAtomicObjectC(rail, owner, asset, amount, spent)
if len(fromVM) != exportedOutputSize {
t.Fatalf("VM object width %d != canonical %d", len(fromVM), exportedOutputSize)
}
if len(fromPrecompile) != exportedOutputSize {
t.Fatalf("precompile object width %d != canonical %d", len(fromPrecompile), exportedOutputSize)
}
for i := range fromVM {
if fromVM[i] != fromPrecompile[i] {
t.Fatalf("rail %d spent %d wire drift at byte %d: VM=%02x precompile=%02x", rail, spent, i, fromVM[i], fromPrecompile[i])
}
}
// And the VM decodes the precompile-shaped object to the same fields incl. spent.
r, o, a, amt, sp, ok := decodeExportedOutput(fromPrecompile)
if !ok || r != rail || o != owner || a != asset || amt != amount || sp != spent {
t.Fatalf("VM decode of precompile object mismatch: ok=%v rail=%d owner=%v asset=%v amt=%d spent=%d", ok, r, o, a, amt, sp)
}
}
}
}
// TestNativeSeam_CtoD_ImportsPrecompileObject drives the REAL executeImport on a
// C->D object written in the PRECOMPILE's wire format. This is the D side of the
// C->D leg: D is funded ONLY by consuming the C->D object the precompile wrote.
func TestNativeSeam_CtoD_ImportsPrecompileObject(t *testing.T) {
h := newCustodyHarness(t)
owner := ids.GenerateTestShortID()
token := ids.GenerateTestID()
const amount = 5000
// Write the C->D object EXACTLY as the precompile's SubmitSwapIntent would
// (precompile-shaped bytes), into the shared-memory partition the proxy imports
// from. fundCChain uses encodeExportedOutput, proven byte-identical above, so this
// is the precompile's wire on the wire.
utxo := h.fundCChain(t, owner, token, amount)
// REAL executeImport consumes it and credits the D-side ledger in the consumed
// asset for exactly the amount — the native funding of the D order/position.
ar := newAtomicRequests()
tx := txs.NewImportTx(owner, 0, h.cChain,
[]txs.AtomicInput{{UTXOID: utxo, Asset: token, Amount: amount}},
[]txs.AtomicOutput{{Owner: owner, Asset: token, Amount: amount}})
if err := h.vm.executeImport(tx, ar); err != nil {
t.Fatalf("executeImport of precompile-shaped C->D object: %v", err)
}
// The import accumulated a Remove of the consumed object against the source chain
// (the atomic consume committed at accept) — D consumed the C->D object exactly
// once. A second import of the same object is replay-rejected (proven by the
// existing TestImportReplaySameUTXORejects).
req, ok := ar.reqs[h.cChain]
if !ok || len(req.RemoveRequests) != 1 {
t.Fatalf("import must accumulate exactly one atomic Remove of the consumed C->D object")
}
}
// TestNativeSeam_DtoC_ExportDecodesAsPrecompileObject drives the REAL executeExport
// (D->C leg) and asserts the produced object decodes under the PRECOMPILE's wire
// shape — so the precompile's ImportSettlement consumes it natively. This is the
// D side of the D->C leg: C is credited ONLY by consuming this object.
func TestNativeSeam_DtoC_ExportDecodesAsPrecompileObject(t *testing.T) {
h := newCustodyHarness(t)
owner := ids.GenerateTestShortID()
token := ids.GenerateTestID()
const amount = 90
// REAL executeExport writes a D->C object (the settlement proceeds) into shared
// memory keyed by the destination (C) chain.
ar := newAtomicRequests()
exp := txs.NewSettlementExportTx(owner, 0, h.cChain,
[]txs.AtomicOutput{{Owner: owner, Asset: token, Amount: amount}},
ids.GenerateTestID(), 12345)
if err := h.vm.executeExport(exp, ar); err != nil {
t.Fatalf("executeExport: %v", err)
}
req, ok := ar.reqs[h.cChain]
if !ok || len(req.PutRequests) != 1 {
t.Fatalf("export must produce exactly one D->C object")
}
obj := req.PutRequests[0].Value
// The precompile decodes this object (rail|owner|asset|amount) and binds the credit
// to it. Replicate the precompile's decode (decodeAtomicObject) at the canonical
// offsets and assert it reads the exact recorded value — i.e. the precompile would
// credit `owner` exactly `amount` of `token` on the swap rail (a fill export).
if len(obj) != exportedOutputSize {
t.Fatalf("D->C object width %d != canonical %d", len(obj), exportedOutputSize)
}
pRail := txs.Rail(obj[0])
var pOwner ids.ShortID
copy(pOwner[:], obj[1:21])
var pAsset ids.ID
copy(pAsset[:], obj[21:53])
pAmount := binary.BigEndian.Uint64(obj[53:61])
if pRail != txs.RailSwap || pOwner != owner || pAsset != token || pAmount != amount {
t.Fatalf("precompile decode of D->C object mismatch: rail=%d owner=%v asset=%v amount=%d", pRail, pOwner, pAsset, pAmount)
}
// The export Trait is the owner address (so the precompile / destination indexes
// the object by recipient) — the same Trait the precompile filters on.
if len(req.PutRequests[0].Traits) != 1 || string(req.PutRequests[0].Traits[0]) != string(owner[:]) {
t.Fatalf("D->C object Trait must be the owner address for recipient indexing")
}
}
// TestNativeSeam_FullRoundTrip drives BOTH real executors in sequence: a
// precompile-shaped C->D object is imported (funding D), then D exports a D->C
// object that decodes under the precompile wire (crediting C) — the complete
// C->D->C round trip across the real dexvm atomic executors.
func TestNativeSeam_FullRoundTrip(t *testing.T) {
h := newCustodyHarness(t)
_ = context.Background()
taker := ids.GenerateTestShortID()
tokenIn := ids.GenerateTestID()
tokenOut := ids.GenerateTestID()
// --- C->D: import the precompile-shaped intent object (D funded). ---
inUTXO := h.fundCChain(t, taker, tokenIn, 100)
arIn := newAtomicRequests()
imp := txs.NewImportTx(taker, 0, h.cChain,
[]txs.AtomicInput{{UTXOID: inUTXO, Asset: tokenIn, Amount: 100}},
[]txs.AtomicOutput{{Owner: taker, Asset: tokenIn, Amount: 100}})
if err := h.vm.executeImport(imp, arIn); err != nil {
t.Fatalf("round-trip C->D import: %v", err)
}
// --- D matches (modeled): 100 tokenIn -> 90 tokenOut. ---
// --- D->C: export the settlement object (C credited on import). ---
arOut := newAtomicRequests()
exp := txs.NewSettlementExportTx(taker, 0, h.cChain,
[]txs.AtomicOutput{{Owner: taker, Asset: tokenOut, Amount: 90}},
inUTXO, 777)
if err := h.vm.executeExport(exp, arOut); err != nil {
t.Fatalf("round-trip D->C export: %v", err)
}
// The D->C object decodes to (taker, tokenOut, 90) under the precompile wire —
// the precompile would credit the taker exactly 90 tokenOut. Conservation: the
// taker's 100 tokenIn was consumed on C (import Remove) and 90 tokenOut returns
// (export Put) — value moves only via these atomic objects, never minted.
obj := arOut.reqs[h.cChain].PutRequests[0].Value
r, o, a, amt, _, ok := decodeExportedOutput(obj)
if !ok || r != txs.RailSwap || o != taker || a != tokenOut || amt != 90 {
t.Fatalf("round-trip D->C object mismatch: ok=%v rail=%d owner=%v asset=%v amt=%d", ok, r, o, a, amt)
}
if len(arIn.reqs[h.cChain].RemoveRequests) != 1 {
t.Fatalf("round-trip: C->D object must be consumed exactly once")
}
}
-598
View File
@@ -1,598 +0,0 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package network provides peer-to-peer networking and Warp messaging for the DEX VM.
package network
import (
"context"
"encoding/binary"
"errors"
"sync"
"sync/atomic"
"time"
"github.com/luxfi/ids"
"github.com/luxfi/log"
)
var (
ErrInvalidMessage = errors.New("invalid message")
ErrUnknownMessageType = errors.New("unknown message type")
ErrPeerNotConnected = errors.New("peer not connected")
ErrRequestTimeout = errors.New("request timed out")
)
// MessageType represents the type of network message.
type MessageType uint8
const (
MsgOrderGossip MessageType = iota
MsgTradeGossip
MsgOrderbookSync
MsgPoolSync
MsgCrossChainSwap
MsgCrossChainTransfer
MsgWarpMessage
)
func (t MessageType) String() string {
switch t {
case MsgOrderGossip:
return "order_gossip"
case MsgTradeGossip:
return "trade_gossip"
case MsgOrderbookSync:
return "orderbook_sync"
case MsgPoolSync:
return "pool_sync"
case MsgCrossChainSwap:
return "cross_chain_swap"
case MsgCrossChainTransfer:
return "cross_chain_transfer"
case MsgWarpMessage:
return "warp_message"
default:
return "unknown"
}
}
// Message represents a network message.
type Message struct {
Type MessageType
RequestID uint32
Payload []byte
ChainID ids.ID // For cross-chain messages
Sender ids.NodeID
Timestamp int64
}
// Encode encodes the message to bytes.
func (m *Message) Encode() []byte {
// Format: type (1) + requestID (4) + chainID (32) + sender (20) + timestamp (8) + payloadLen (4) + payload
size := 1 + 4 + 32 + 20 + 8 + 4 + len(m.Payload)
data := make([]byte, size)
offset := 0
data[offset] = byte(m.Type)
offset++
binary.BigEndian.PutUint32(data[offset:], m.RequestID)
offset += 4
copy(data[offset:], m.ChainID[:])
offset += 32
copy(data[offset:], m.Sender[:])
offset += 20
binary.BigEndian.PutUint64(data[offset:], uint64(m.Timestamp))
offset += 8
binary.BigEndian.PutUint32(data[offset:], uint32(len(m.Payload)))
offset += 4
copy(data[offset:], m.Payload)
return data
}
// DecodeMessage decodes a message from bytes.
func DecodeMessage(data []byte) (*Message, error) {
if len(data) < 69 { // Minimum size
return nil, ErrInvalidMessage
}
m := &Message{}
offset := 0
m.Type = MessageType(data[offset])
offset++
m.RequestID = binary.BigEndian.Uint32(data[offset:])
offset += 4
copy(m.ChainID[:], data[offset:offset+32])
offset += 32
copy(m.Sender[:], data[offset:offset+20])
offset += 20
m.Timestamp = int64(binary.BigEndian.Uint64(data[offset:]))
offset += 8
payloadLen := binary.BigEndian.Uint32(data[offset:])
offset += 4
if offset+int(payloadLen) > len(data) {
return nil, ErrInvalidMessage
}
m.Payload = make([]byte, payloadLen)
copy(m.Payload, data[offset:offset+int(payloadLen)])
return m, nil
}
// Handler handles network messages for the DEX VM.
type Handler struct {
mu sync.RWMutex
log log.Logger
chainID ids.ID
// Pending requests
pendingRequests map[uint32]chan *Message
nextRequestID uint32
// Message handlers
orderHandler func(*Message) error
tradeHandler func(*Message) error
syncHandler func(*Message) error
warpHandler func(*Message) error
// Statistics (atomic for thread-safe access)
messagesSent atomic.Uint64
messagesReceived atomic.Uint64
bytesIn atomic.Uint64
bytesOut atomic.Uint64
}
// NewHandler creates a new network handler.
func NewHandler(log log.Logger, chainID ids.ID) *Handler {
return &Handler{
log: log,
chainID: chainID,
pendingRequests: make(map[uint32]chan *Message),
}
}
// SetOrderHandler sets the handler for order gossip messages.
func (h *Handler) SetOrderHandler(handler func(*Message) error) {
h.mu.Lock()
defer h.mu.Unlock()
h.orderHandler = handler
}
// SetTradeHandler sets the handler for trade gossip messages.
func (h *Handler) SetTradeHandler(handler func(*Message) error) {
h.mu.Lock()
defer h.mu.Unlock()
h.tradeHandler = handler
}
// SetSyncHandler sets the handler for sync messages.
func (h *Handler) SetSyncHandler(handler func(*Message) error) {
h.mu.Lock()
defer h.mu.Unlock()
h.syncHandler = handler
}
// SetWarpHandler sets the handler for Warp messages.
func (h *Handler) SetWarpHandler(handler func(*Message) error) {
h.mu.Lock()
defer h.mu.Unlock()
h.warpHandler = handler
}
// HandleGossip handles an incoming gossip message.
func (h *Handler) HandleGossip(ctx context.Context, nodeID ids.NodeID, msgBytes []byte) error {
msg, err := DecodeMessage(msgBytes)
if err != nil {
h.log.Warn("Failed to decode gossip message", "error", err)
return err
}
msg.Sender = nodeID
// Use atomic counters - no lock needed for statistics
h.messagesReceived.Add(1)
h.bytesIn.Add(uint64(len(msgBytes)))
// Copy handler reference under lock to avoid race with SetXxxHandler
h.mu.RLock()
orderHandler := h.orderHandler
tradeHandler := h.tradeHandler
h.mu.RUnlock()
switch msg.Type {
case MsgOrderGossip:
if orderHandler != nil {
return orderHandler(msg)
}
case MsgTradeGossip:
if tradeHandler != nil {
return tradeHandler(msg)
}
default:
return ErrUnknownMessageType
}
return nil
}
// HandleRequest handles an incoming request message.
func (h *Handler) HandleRequest(
ctx context.Context,
nodeID ids.NodeID,
requestID uint32,
deadline time.Time,
msgBytes []byte,
) ([]byte, error) {
msg, err := DecodeMessage(msgBytes)
if err != nil {
return nil, err
}
msg.Sender = nodeID
msg.RequestID = requestID
// Use atomic counters - no lock needed for statistics
h.messagesReceived.Add(1)
h.bytesIn.Add(uint64(len(msgBytes)))
// Copy handler reference under lock to avoid race with SetXxxHandler
h.mu.RLock()
syncHandler := h.syncHandler
h.mu.RUnlock()
var response *Message
switch msg.Type {
case MsgOrderbookSync:
if syncHandler != nil {
if err := syncHandler(msg); err != nil {
return nil, err
}
}
response = &Message{
Type: MsgOrderbookSync,
RequestID: requestID,
ChainID: h.chainID,
Timestamp: time.Now().UnixNano(),
}
case MsgPoolSync:
if syncHandler != nil {
if err := syncHandler(msg); err != nil {
return nil, err
}
}
response = &Message{
Type: MsgPoolSync,
RequestID: requestID,
ChainID: h.chainID,
Timestamp: time.Now().UnixNano(),
}
default:
return nil, ErrUnknownMessageType
}
return response.Encode(), nil
}
// HandleResponse handles a response to a previously sent request.
func (h *Handler) HandleResponse(ctx context.Context, nodeID ids.NodeID, requestID uint32, responseBytes []byte) error {
h.mu.Lock()
respChan, exists := h.pendingRequests[requestID]
if !exists {
h.mu.Unlock()
return nil // Request may have timed out
}
delete(h.pendingRequests, requestID)
h.mu.Unlock()
msg, err := DecodeMessage(responseBytes)
if err != nil {
return err
}
msg.Sender = nodeID
msg.RequestID = requestID
select {
case respChan <- msg:
default:
// Channel full or closed
}
return nil
}
// HandleCrossChainRequest handles a cross-chain request via Warp.
func (h *Handler) HandleCrossChainRequest(
ctx context.Context,
sourceChainID ids.ID,
requestID uint32,
deadline time.Time,
msgBytes []byte,
) ([]byte, error) {
msg, err := DecodeMessage(msgBytes)
if err != nil {
return nil, err
}
msg.ChainID = sourceChainID
msg.RequestID = requestID
h.log.Debug("Received cross-chain request",
"sourceChain", sourceChainID,
"type", msg.Type,
"requestID", requestID,
)
// Copy handler reference under lock to avoid race with SetXxxHandler
h.mu.RLock()
warpHandler := h.warpHandler
h.mu.RUnlock()
switch msg.Type {
case MsgCrossChainSwap:
if warpHandler != nil {
if err := warpHandler(msg); err != nil {
return nil, err
}
}
case MsgCrossChainTransfer:
if warpHandler != nil {
if err := warpHandler(msg); err != nil {
return nil, err
}
}
case MsgWarpMessage:
if warpHandler != nil {
if err := warpHandler(msg); err != nil {
return nil, err
}
}
default:
return nil, ErrUnknownMessageType
}
// Create response
response := &Message{
Type: msg.Type,
RequestID: requestID,
ChainID: h.chainID,
Timestamp: time.Now().UnixNano(),
Payload: []byte("ok"),
}
return response.Encode(), nil
}
// HandleCrossChainResponse handles a cross-chain response.
func (h *Handler) HandleCrossChainResponse(
ctx context.Context,
sourceChainID ids.ID,
requestID uint32,
responseBytes []byte,
) error {
h.mu.Lock()
respChan, exists := h.pendingRequests[requestID]
if !exists {
h.mu.Unlock()
return nil
}
delete(h.pendingRequests, requestID)
h.mu.Unlock()
msg, err := DecodeMessage(responseBytes)
if err != nil {
return err
}
msg.ChainID = sourceChainID
msg.RequestID = requestID
select {
case respChan <- msg:
default:
}
return nil
}
// SendRequest sends a request and waits for a response.
func (h *Handler) SendRequest(
ctx context.Context,
nodeID ids.NodeID,
msg *Message,
timeout time.Duration,
sendFunc func(ids.NodeID, uint32, []byte) error,
) (*Message, error) {
h.mu.Lock()
requestID := h.nextRequestID
h.nextRequestID++
respChan := make(chan *Message, 1)
h.pendingRequests[requestID] = respChan
h.mu.Unlock()
defer func() {
h.mu.Lock()
delete(h.pendingRequests, requestID)
h.mu.Unlock()
}()
msg.RequestID = requestID
msg.ChainID = h.chainID
msg.Timestamp = time.Now().UnixNano()
msgBytes := msg.Encode()
// Use atomic counters - no lock needed for statistics
h.messagesSent.Add(1)
h.bytesOut.Add(uint64(len(msgBytes)))
if err := sendFunc(nodeID, requestID, msgBytes); err != nil {
return nil, err
}
// Wait for response
select {
case response := <-respChan:
return response, nil
case <-time.After(timeout):
return nil, ErrRequestTimeout
case <-ctx.Done():
return nil, ctx.Err()
}
}
// Gossip sends a gossip message to all peers.
func (h *Handler) Gossip(msg *Message, gossipFunc func([]byte) error) error {
msg.ChainID = h.chainID
msg.Timestamp = time.Now().UnixNano()
msgBytes := msg.Encode()
// Use atomic counters - no lock needed for statistics
h.messagesSent.Add(1)
h.bytesOut.Add(uint64(len(msgBytes)))
return gossipFunc(msgBytes)
}
// Stats returns network statistics.
func (h *Handler) Stats() (sent, received, bytesIn, bytesOut uint64) {
// Use atomic Load - no lock needed for statistics
return h.messagesSent.Load(), h.messagesReceived.Load(), h.bytesIn.Load(), h.bytesOut.Load()
}
// WarpManager manages cross-chain Warp messaging.
type WarpManager struct {
mu sync.RWMutex
log log.Logger
chainID ids.ID
trustedChains map[ids.ID]bool
pendingMessages map[ids.ID]*WarpMessage
}
// WarpMessage represents a cross-chain Warp message.
type WarpMessage struct {
ID ids.ID
SourceChain ids.ID
DestChain ids.ID
Payload []byte
Signature []byte
Validators []ids.NodeID
ValidatorSigs [][]byte
Timestamp int64
Deadline int64
}
// NewWarpManager creates a new Warp manager.
func NewWarpManager(log log.Logger, chainID ids.ID, trustedChains []ids.ID) *WarpManager {
trusted := make(map[ids.ID]bool)
for _, chain := range trustedChains {
trusted[chain] = true
}
return &WarpManager{
log: log,
chainID: chainID,
trustedChains: trusted,
pendingMessages: make(map[ids.ID]*WarpMessage),
}
}
// IsTrustedChain returns true if the chain is trusted for cross-chain messaging.
func (w *WarpManager) IsTrustedChain(chainID ids.ID) bool {
w.mu.RLock()
defer w.mu.RUnlock()
return w.trustedChains[chainID]
}
// AddTrustedChain adds a chain to the trusted list.
func (w *WarpManager) AddTrustedChain(chainID ids.ID) {
w.mu.Lock()
defer w.mu.Unlock()
w.trustedChains[chainID] = true
}
// RemoveTrustedChain removes a chain from the trusted list.
func (w *WarpManager) RemoveTrustedChain(chainID ids.ID) {
w.mu.Lock()
defer w.mu.Unlock()
delete(w.trustedChains, chainID)
}
// ProcessIncomingMessage processes an incoming Warp message.
func (w *WarpManager) ProcessIncomingMessage(msg *WarpMessage) error {
// Verify source chain is trusted
if !w.IsTrustedChain(msg.SourceChain) {
return errors.New("source chain not trusted")
}
// Verify message is for this chain
if msg.DestChain != w.chainID {
return errors.New("message not for this chain")
}
// Verify deadline
if msg.Deadline > 0 && time.Now().UnixNano() > msg.Deadline {
return errors.New("message expired")
}
// Store pending message
w.mu.Lock()
w.pendingMessages[msg.ID] = msg
w.mu.Unlock()
w.log.Debug("Received Warp message",
"id", msg.ID,
"source", msg.SourceChain,
)
return nil
}
// CreateOutgoingMessage creates a Warp message to send to another chain.
func (w *WarpManager) CreateOutgoingMessage(
destChain ids.ID,
payload []byte,
deadline time.Duration,
) *WarpMessage {
return &WarpMessage{
ID: ids.GenerateTestID(),
SourceChain: w.chainID,
DestChain: destChain,
Payload: payload,
Timestamp: time.Now().UnixNano(),
Deadline: time.Now().Add(deadline).UnixNano(),
}
}
// GetPendingMessage returns a pending Warp message.
func (w *WarpManager) GetPendingMessage(id ids.ID) (*WarpMessage, bool) {
w.mu.RLock()
defer w.mu.RUnlock()
msg, exists := w.pendingMessages[id]
return msg, exists
}
// RemovePendingMessage removes a pending Warp message.
func (w *WarpManager) RemovePendingMessage(id ids.ID) {
w.mu.Lock()
defer w.mu.Unlock()
delete(w.pendingMessages, id)
}
-835
View File
@@ -1,835 +0,0 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//go:build redteam
// redteam_atomicity_test.go — RED TEAM adversarial proofs that the proxy<->d-chain
// boundary is NOT atomic. These tests EXERCISE THE FAILURE PATHS the happy-path
// conservation_test.go never touches: verify-then-reject, re-verify (reorg/
// restart), and fractional-fill truncation.
//
// They are written to FAIL against the current code to demonstrate the bugs.
// A counter on the fake matcher records how many times the d-chain was actually
// hit, which is the ground truth of "did value move on the d-chain".
package dexvm
import (
"bytes"
"context"
"encoding/binary"
"math/big"
"sort"
"sync"
"testing"
"time"
"github.com/luxfi/database/memdb"
"github.com/luxfi/database/prefixdb"
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/runtime"
"github.com/luxfi/vm"
"github.com/luxfi/vm/chains/atomic"
"github.com/luxfi/warp"
"github.com/luxfi/chains/dexvm/config"
"github.com/luxfi/chains/dexvm/txs"
)
// countingMatcher is a fake d-chain matcher that COUNTS how many times each
// clob_* method was invoked — the ground truth for "how many real matches /
// placements happened on the d-chain". Each clob_submit returns the canned fills.
type countingMatcher struct {
mu sync.Mutex
fills []Fill
submits int
places int
cancels int
}
func (m *countingMatcher) Call(_ context.Context, method string, _ []byte) ([]byte, error) {
m.mu.Lock()
defer m.mu.Unlock()
switch method {
case ZAPMethodSubmit:
m.submits++
return encodeFillsWire(m.fills), nil
case ZAPMethodPlace:
m.places++
case ZAPMethodCancel:
m.cancels++
}
ack := make([]byte, 17)
binary.BigEndian.PutUint64(ack[0:8], 1)
return ack, nil
}
func (m *countingMatcher) Close() error { return nil }
func (m *countingMatcher) counts() (int, int, int) {
m.mu.Lock()
defer m.mu.Unlock()
return m.submits, m.places, m.cancels
}
// newCountingHarness wires a ChainVM (the real block-lifecycle wrapper) with a
// counting matcher and real two-chain shared memory, so we can drive
// Verify/Accept/Reject exactly as consensus does.
func newCountingHarness(t *testing.T, fills []Fill) (*ChainVM, *countingMatcher, atomic.SharedMemory, ids.ID, ids.ID) {
t.Helper()
logger := log.NewNoOpLogger()
baseDB := memdb.New()
memoryDB := prefixdb.New([]byte{0}, baseDB)
m := atomic.NewMemory(memoryDB)
proxyChain := ids.GenerateTestID()
cChain := ids.GenerateTestID()
proxySM := m.NewSharedMemory(proxyChain)
cChainSM := m.NewSharedMemory(cChain)
matcher := &countingMatcher{fills: fills}
prev := zapDialer
zapDialer = func(_ context.Context, _ string) (zapConn, error) { return matcher, nil }
t.Cleanup(func() { zapDialer = prev })
rt := &runtime.Runtime{
ChainID: proxyChain,
CChainID: cChain,
NetworkID: 96369,
Log: logger,
SharedMemory: proxySM,
}
cvm := NewChainVM(logger)
cvm.inner.Config = config.DefaultConfig()
cvm.inner.Config.DexZapEndpoint = "127.0.0.1:0"
if err := cvm.Initialize(context.Background(), vm.Init{
Runtime: rt,
DB: prefixdb.New([]byte{1}, baseDB),
ToEngine: make(chan vm.Message, 8),
Sender: warp.FakeSender{},
Log: logger,
}); err != nil {
t.Fatalf("init chain vm: %v", err)
}
cvm.inner.bootstrapped = true
return cvm, matcher, cChainSM, proxyChain, cChain
}
// seedExportedUTXO makes the C-Chain export `amount` of `asset` to the proxy so
// the proxy's import has something to consume. Returns the source UTXO id.
func seedExportedUTXO(t *testing.T, cChainSM atomic.SharedMemory, proxyChain ids.ID, owner ids.ShortID, asset ids.ID, amount uint64) ids.ID {
t.Helper()
srcUTXOID := deriveUTXOID(ids.GenerateTestID(), 0)
val := encodeExportedOutput(txs.AtomicOutput{Owner: owner, Asset: asset, Amount: amount})
if err := cChainSM.Apply(map[ids.ID]*atomic.Requests{
proxyChain: {PutRequests: []*atomic.Element{{
Key: srcUTXOID[:],
Value: val,
Traits: [][]byte{owner[:]},
}}},
}); err != nil {
t.Fatalf("seed exported UTXO: %v", err)
}
return srcUTXOID
}
// makeBlock builds a *Block directly (bypassing BuildBlock's now() timestamp) so
// the test controls (height, time) deterministically.
func makeBlock(cvm *ChainVM, parentID ids.ID, height uint64, ts time.Time, txBytes [][]byte) *Block {
b := &Block{
vm: cvm,
id: deriveBlockHash(height, ts), // any stable id is fine for the test
parentID: parentID,
height: height,
timestamp: ts,
txs: txBytes,
status: StatusUnknown,
}
cvm.blocks[b.id] = b
return b
}
// TestRED_VerifyThenReject_StrandsDChainFill is the regression proof that the
// VALIDATOR path (Verify/Accept) is relay-free and that a rejected block moves no
// C-side value. Under the carried-fills model (RED #9) the d-chain relay happens
// exactly ONCE, at the PROPOSER's build; Verify and Accept never relay on ANY node.
// It asserts:
//
// - A non-proposer block (no carried fills) Verified then Rejected: NEVER hits
// the d-chain at Verify/Accept, and Reject moves value on NEITHER side (no
// C-credit, the consumed UTXO is rolled back) — a plain db.Abort.
// - The PROPOSER build+accept (positive control) moves value on both sides,
// atomically: exactly one d-chain submit AT BUILD, the C-Chain credited the
// filled proceeds, and the source UTXO consumed — all-or-nothing.
//
// NOTE on the build-relay trust surface (documented in carried_fills.go): a block
// the proposer BUILT (relayed) but that later loses the poll DOES strand a d-chain
// match. That is the bounded, interim proposer-trust cost of carried-fills
// determinism (single-operator venue today; trustless via the reserved fill-sig).
// This test exercises the validator path (makeBlock, no build), which never relays,
// so its reject strands nothing.
func TestRED_VerifyThenReject_StrandsDChainFill(t *testing.T) {
fills := []Fill{{Price: fp(1), Size: 1000, Side: 0}} // taker BUY, receives 1000 base
cvm, matcher, cChainSM, proxyChain, _ := newCountingHarness(t, fills)
ctx := context.Background()
taker := ids.GenerateTestShortID()
asset := ids.GenerateTestID()
srcUTXOID := seedExportedUTXO(t, cChainSM, proxyChain, taker, asset, 1000)
importTx := newImportTxBytes(t, taker, cvm.inner.cChainID(), srcUTXOID, asset, 1000)
relayTx := newRelayTxBytes(t, taker, srcUTXOID, clobSubmitPayload(asset, 1000))
// A VALIDATOR's view of a block (makeBlock: no carried fills, not built here).
// Verify+Reject must never relay and must move nothing.
blk := makeBlock(cvm, cvm.lastAcceptedID, 1, time.Unix(1, 0), [][]byte{importTx, relayTx})
if err := blk.Verify(ctx); err != nil {
t.Fatalf("verify: %v", err)
}
if submits, _, _ := matcher.counts(); submits != 0 {
t.Fatalf("ATOMICITY REGRESSION: Verify hit the d-chain %d times — Verify runs on "+
"every validator and must NEVER relay (the proposer relays once at build).", submits)
}
// Consensus REJECTS the block (a conflicting block won the poll). A correct
// Reject is a plain db.Abort() with NOTHING to compensate on the C-side.
if err := blk.Reject(ctx); err != nil {
t.Fatalf("reject: %v", err)
}
submits, _, _ := matcher.counts()
credited := creditedTo(t, cChainSM, proxyChain, taker)
consumed, _ := cvm.inner.state.IsConsumed(srcUTXOID)
t.Logf("AFTER VERIFY+REJECT (validator path): d-chain submits=%d C-Chain credited=%d utxo consumed=%v",
submits, credited, consumed)
if submits != 0 || credited != 0 || consumed {
t.Fatalf("ATOMICITY VIOLATED on reject: submits=%d credited=%d consumed=%v — a rejected "+
"block on the validator path must move value on NEITHER chain (no relay at "+
"Verify/Accept, no C-Chain credit, source UTXO rolled back).", submits, credited, consumed)
}
// POSITIVE CONTROL: the PROPOSER builds (relays ONCE at build, carries fills)
// and accepts. Value MUST move on both sides atomically, with exactly one
// d-chain submit performed at build.
cvm.inner.clock.Set(time.Unix(1, 0))
cvm.pendingTxs = [][]byte{importTx, relayTx}
built, err := cvm.BuildBlock(ctx)
if err != nil {
t.Fatalf("BuildBlock (proposer): %v", err)
}
if err := cvm.SetPreference(ctx, built.ID()); err != nil {
t.Fatalf("SetPreference: %v", err)
}
if err := built.Verify(ctx); err != nil {
t.Fatalf("verify built: %v", err)
}
if err := built.Accept(ctx); err != nil {
t.Fatalf("accept built: %v", err)
}
submits, _, _ = matcher.counts()
credited = creditedTo(t, cChainSM, proxyChain, taker)
consumed, _ = cvm.inner.state.IsConsumed(srcUTXOID)
t.Logf("AFTER PROPOSER BUILD+ACCEPT: d-chain submits=%d C-Chain credited=%d utxo consumed=%v",
submits, credited, consumed)
if submits != 1 || credited != 1000 || !consumed {
t.Fatalf("ATOMIC SETTLE BROKEN: submits=%d (want 1) credited=%d (want 1000) "+
"consumed=%v (want true) — the proposer must relay once AT BUILD, and accept must "+
"credit the C-Chain AND consume the source UTXO together.", submits, credited, consumed)
}
}
// creditedTo sums the value the proxy exported to `owner` on the C-Chain, read
// through the C-Chain's Indexed view on the owner trait. Each element value is
// owner(20)|asset(32)|amount(8).
func creditedTo(t *testing.T, cChainSM atomic.SharedMemory, proxyChain ids.ID, owner ids.ShortID) uint64 {
t.Helper()
vals, _, _, err := cChainSM.Indexed(proxyChain, [][]byte{owner[:]}, nil, nil, 100)
if err != nil {
t.Fatalf("indexed: %v", err)
}
var credited uint64
for _, v := range vals {
if len(v) >= exportedOutputSize {
credited += binary.BigEndian.Uint64(v[53:61])
}
}
return credited
}
// TestRED_ReVerify_DoublesPlaceOrder proves place-order relays have NO
// idempotency guard: re-verifying the same block (restart/reorg replay) re-sends
// clob_place, duplicating the resting maker order on the d-chain.
func TestRED_ReVerify_DoublesPlaceOrder(t *testing.T) {
cvm, matcher, _, _, _ := newCountingHarness(t, nil)
ctx := context.Background()
maker := ids.GenerateTestShortID()
pid := ids.GenerateTestID()
var poolID [32]byte
copy(poolID[:], pid[:])
placeTx := txs.NewPlaceOrderTx(maker, 0, poolID, 1 /*sell*/, 100, 5).Bytes()
blk := makeBlock(cvm, cvm.lastAcceptedID, 1, time.Unix(1, 0), [][]byte{placeTx})
// First Verify (normal). Then a SECOND Verify simulating restart-before-accept
// or a reorg that re-processes the same height.
if err := blk.Verify(ctx); err != nil {
t.Fatalf("verify #1: %v", err)
}
if err := blk.Verify(ctx); err != nil {
t.Fatalf("verify #2: %v", err)
}
_, places, _ := matcher.counts()
t.Logf("clob_place sent to d-chain after 2 verifies: %d", places)
if places > 1 {
t.Fatalf("ORDER DUPLICATION: a single place-order tx, re-verified, sent %d "+
"clob_place frames to the d-chain — the maker now has %dx the liquidity "+
"resting they intended (no receipt/idempotency guard on executePlaceOrder).", places, places)
}
}
// TestRED_FractionalFill_NeverMints is the fractional-QUOTE conservation proof.
// The wire now carries EXACT integers (size in whole base units, price fixed-point
// ×PriceScale), so a fill's quote = floor(size × price / PriceScale) is itself an
// exact integer — the SAME per-fill floor the venue matcher moved. There is no
// float and no directional rounding seam; the proxy spends precisely the venue's
// quote and the escrow-truncation mint is structurally impossible.
//
// This pins the behavior with an ESCROW present so both legs are auditable: a taker
// BUY locks 10 quote and fills 3 base @ price 2.5 (fixed-point fp(2.5)). The quote =
// floor(3 × 2.5) = floor(7.5) = 7 (the venue's own integer quote), base proceeds =
// 3; value_out (3 base + 3 quote refund) must never exceed value_in, and the
// locked-quote ledger must balance exactly (spent 7 + refund 3 == locked 10).
func TestRED_FractionalFill_NeverMints(t *testing.T) {
// BUY: receives base = Σ size = 3. spends quote = floor(3 × fp(2.5)/PriceScale) =
// floor(7.5) = 7. locked 10 quote -> refund 10-7 = 3.
fills := []Fill{{Price: fp(2.5), Size: 3, Side: 0}}
cvm, _, _, _, _ := newCountingHarness(t, fills)
taker := ids.GenerateTestShortID()
collateralRef := ids.GenerateTestID()
lockedAsset := ids.GenerateTestID()
const lockedQuote uint64 = 10
if err := cvm.inner.state.PutEscrow(collateralRef, taker, lockedAsset, lockedQuote); err != nil {
t.Fatalf("seed escrow: %v", err)
}
ar := newAtomicRequests()
if err := cvm.inner.settleFromFills(taker, collateralRef, fills, ids.GenerateTestID(), 0, false, ids.GenerateTestID(), 0, ar); err != nil {
t.Fatalf("settleFromFills: %v", err)
}
// Split the exported value by asset: base proceeds vs the locked-quote refund.
// Export wire is rail(1)|owner(20)|asset(32)|amount(8) (encodeExportedOutput), so
// asset is [21:53] and amount [53:61].
var base, refund uint64
for _, reqs := range ar.reqs {
for _, e := range reqs.PutRequests {
if len(e.Value) != exportedOutputSize {
continue
}
var a ids.ID
copy(a[:], e.Value[21:53])
amt := binary.BigEndian.Uint64(e.Value[53:61])
if a == lockedAsset {
refund += amt
} else {
base += amt
}
}
}
t.Logf("fractional-quote fill: base proceeds=%d (Σ size = 3) quote refund=%d (locked 10 - floor(7.5)=7)", base, refund)
// Proceeds are the exact base traded: never credit more base than Σ size.
if base > 3 {
t.Fatalf("MINT: credited %d base for a 3-base fill — proceeds must equal Σ size = 3, never more.", base)
}
// Spent quote floors to 7 (the venue's exact quote), so refund is exactly 3 —
// never inflated (that would be the escrow-truncation mint), never negative.
if refund != lockedQuote-7 {
t.Fatalf("CONSERVATION VIOLATED: quote refund=%d, want %d (locked 10 - spent floor(7.5)=7).", refund, lockedQuote-7)
}
// No-mint over the locked-quote ledger: spent(7) + refund(3) == locked(10).
if spent := lockedQuote - refund; spent != 7 {
t.Fatalf("NO-MINT VIOLATED: spent+refund != locked (spent=%d refund=%d locked=%d).", spent, refund, lockedQuote)
}
}
// TestRED_OverflowFill_SaturatesUint64 is the exact-integer OVERFLOW-REFUSAL proof
// — the headline of the finding: an aggregate fill amount that exceeds what a uint64
// can hold must be REFUSED at the asset boundary, never silently wrapped/truncated
// into a valid-looking on-chain amount.
//
// The old float64->uint64 cast SATURATED on overflow (uint64(19e18) -> uint64 max),
// minting a plausible ~18.44-token value. The exact-integer wire removes the float
// entirely: base = Σ size and quote = Σ floor(size × price / PriceScale) are summed
// in big.Int, and settleUint64 REFUSES any aggregate that does not fit uint64 (via
// big.Int.IsUint64) BEFORE it becomes an export amount. So an oversized aggregate
// makes settleFromFills ERROR and export NOTHING — refusal, never a wrapped mint.
//
// Two legs are exercised, since the finding's PoC inflates BOTH the proceeds
// aggregate (Σ size) and the spent/notional aggregate (Σ quote):
// - PROCEEDS overflow: a taker BUY whose base size SUM exceeds uint64 max.
// - NOTIONAL overflow: a taker BUY whose quote notional overflows uint64 while the
// base stays in range, which would understate spent => inflate the escrow refund.
func TestRED_OverflowFill_SaturatesUint64(t *testing.T) {
auditCredited := func(ar *atomicRequests) uint64 {
var total uint64
for _, reqs := range ar.reqs {
for _, e := range reqs.PutRequests {
if len(e.Value) >= exportedOutputSize {
total += binary.BigEndian.Uint64(e.Value[53:61])
}
}
}
return total
}
// --- Leg 1: PROCEEDS overflow (Σ size > uint64 max) -------------------------
// taker BUY receives base = Σ size = uint64max + 2 -> overflows; settle must refuse.
t.Run("proceeds", func(t *testing.T) {
fills := []Fill{
{Price: fp(1), Size: ^uint64(0), Side: 0},
{Price: fp(1), Size: 2, Side: 0},
}
cvm, _, _, _, _ := newCountingHarness(t, fills)
taker := ids.GenerateTestShortID()
// No escrow: isolate the proceeds aggregate (refund leg absent).
ar := newAtomicRequests()
err := cvm.inner.settleFromFills(taker, ids.GenerateTestID(), fills, ids.GenerateTestID(), 0, false, ids.GenerateTestID(), 0, ar)
credited := auditCredited(ar)
t.Logf("proceeds overflow: settle err=%v exported=%d (Σ size = 2^64+1 must not wrap)", err, credited)
if err == nil {
t.Fatalf("OVERFLOW MINT: settle accepted a base aggregate > uint64 max; it must be "+
"refused at the asset boundary, not wrapped into a uint64 export amount.")
}
if credited != 0 {
t.Fatalf("OVERFLOW MINT: refused settle still exported %d — refusal must export nothing.", credited)
}
})
// --- Leg 2: NOTIONAL overflow (Σ quote > uint64 max) ------------------------
// taker BUY spends quote = Σ floor(size × price / PriceScale); make the NOTIONAL
// overflow while the base size stays in range, so the only overflow is the
// spent/refund leg. A wrapped/understated spent would inflate refund = locked -
// spent => quote minted out of the proxy's own escrow.
t.Run("notional_refund", func(t *testing.T) {
// size 2e10 (base in range); price fp(1e10) -> quote = 2e10 × 1e10 = 2e20 > uint64 max.
fills := []Fill{{Price: fp(1e10), Size: 20_000_000_000, Side: 0}}
cvm, _, _, _, _ := newCountingHarness(t, fills)
taker := ids.GenerateTestShortID()
collateralRef := ids.GenerateTestID()
lockedAsset := ids.GenerateTestID()
const lockedQuote uint64 = 1000 // tiny escrow; an inflated refund would mint
if err := cvm.inner.state.PutEscrow(collateralRef, taker, lockedAsset, lockedQuote); err != nil {
t.Fatalf("seed escrow: %v", err)
}
ar := newAtomicRequests()
err := cvm.inner.settleFromFills(taker, collateralRef, fills, ids.GenerateTestID(), 0, false, ids.GenerateTestID(), 0, ar)
credited := auditCredited(ar)
t.Logf("notional overflow: settle err=%v exported=%d (a saturated/floored spent would inflate the refund)", err, credited)
if err == nil {
t.Fatalf("OVERFLOW MINT: settle accepted a 2e20 quote notional (> uint64 max); a wrapped " +
"spent would inflate refund = locked - spent => minted quote.")
}
if credited != 0 {
t.Fatalf("OVERFLOW MINT: refused settle still exported %d — refusal must export nothing.", credited)
}
// The escrow must NOT have been consumed by a refused settle (so a later,
// well-formed relay can still legitimately refund it).
_, _, _, haveEscrow, eerr := cvm.inner.state.GetEscrow(collateralRef)
if eerr != nil {
t.Fatalf("escrow lookup: %v", eerr)
}
if !haveEscrow {
t.Fatalf("OVERFLOW MINT: refused settle consumed the escrow — a refused settle must leave it intact.")
}
})
}
// TestRED_SettlementBoundary_ExactUint64Edge pins the exact-integer settlement
// boundary (settleUint64) precisely, so a future edit cannot silently widen it past
// the representable range and reintroduce the overflow mint. The seam is now a pure
// big.Int -> uint64 narrowing:
//
// - the largest passing aggregate (uint64 max) narrows IN-RANGE;
// - one above uint64 max (2^64) is REFUSED — the value a wrapped conversion would
// have minted;
// - a negative aggregate (impossible over non-negative summands, but defensive) is
// REFUSED rather than reinterpreted as a huge uint64.
func TestRED_SettlementBoundary_ExactUint64Edge(t *testing.T) {
const u64max = ^uint64(0)
// The largest representable amount passes and round-trips exactly.
maxV := new(big.Int).SetUint64(u64max)
if got, err := settleUint64(maxV, "base"); err != nil || got != u64max {
t.Fatalf("settleUint64(uint64max) = (%d, %v), want (%d, nil)", got, err, u64max)
}
// Exactly one above uint64 max (2^64) MUST be refused — it is the value a wrapped
// conversion would have minted.
over := new(big.Int).Add(maxV, big.NewInt(1))
if _, err := settleUint64(over, "base"); err == nil {
t.Fatalf("OVERFLOW: settleUint64(2^64) accepted a value above uint64 max — must refuse.")
}
// A negative aggregate is refused (never reinterpreted as a large uint64).
if _, err := settleUint64(big.NewInt(-1), "quote"); err == nil {
t.Fatalf("OVERFLOW: settleUint64(-1) accepted a negative aggregate — must refuse.")
}
}
// TestRED_MixedSideFills_OverCredits is the mixed-side over-credit/mint proof,
// now CLOSED: settleFromFills applies ONE direction (fills[0].Side) to the whole
// aggregate, so summing fills of BOTH sides under that one side mints the
// opposite-side volume. A lying/MITM backend (the threat model DecodeFills
// defends against) returns [BUY 10, SELL 1000]; the old code credited base =
// 10+1000 = 1010 for a taker BUY that legitimately filled only 10.
//
// The fix: a single marketable submit takes exactly ONE side, so a fill stream
// that mixes sides is a lying backend and is REFUSED — the proxy exports nothing
// rather than over-crediting. Refusal is strictly conservation-safe: credited=0,
// never the 1010 mint.
func TestRED_MixedSideFills_OverCredits(t *testing.T) {
// First fill BUY 10 base, second claims SELL 1000 base — a stream no honest
// single submit can produce.
fills := []Fill{
{Price: fp(1), Size: 10, Side: 0},
{Price: fp(1), Size: 1000, Side: 1},
}
cvm, _, _, _, _ := newCountingHarness(t, fills)
taker := ids.GenerateTestShortID()
ar := newAtomicRequests()
err := cvm.inner.settleFromFills(taker, ids.GenerateTestID(), fills, ids.GenerateTestID(), 0, false, ids.GenerateTestID(), 0, ar)
var credited uint64
for _, reqs := range ar.reqs {
for _, e := range reqs.PutRequests {
if len(e.Value) >= exportedOutputSize {
credited += binary.BigEndian.Uint64(e.Value[53:61])
}
}
}
t.Logf("mixed-side fills: settle err=%v credited base=%d (honest BUY-leg total would be 10, mint would be 1010)", err, credited)
// The mixed-side stream MUST be refused, and refusal must export nothing.
if err == nil {
t.Fatalf("OVER-CREDIT/MINT: settle accepted a mixed-side fill stream (credited base=%d); "+
"a single submit takes one side — the stream must be refused.", credited)
}
if credited != 0 {
t.Fatalf("OVER-CREDIT/MINT: refused settle still exported %d base — refusal must accumulate nothing.", credited)
}
}
// TestRED_DecodeFills_RejectsInvalidSideByte is the WIRE-BOUNDARY proof for the
// other half of the finding: "DecodeFills validates positivity but NOT side
// consistency." A lying/MITM backend can put ANY byte in the side field. Side is
// a 2-value enum (0=BUY, 1=SELL); a byte outside {0,1} is malformed wire exactly
// like a NaN price. DecodeFills MUST reject it at the boundary so no impossible
// Fill value ever reaches settlement — never silently coerced, never defaulted.
//
// Without the boundary check, a side byte of e.g. 7 slips past DecodeFills and is
// caught downstream only incidentally by settle's consistency check (7 != 0). The
// boundary is the correct, single home for per-fill side validity.
func TestRED_DecodeFills_RejectsInvalidSideByte(t *testing.T) {
// Two structurally-valid fills; poison the SECOND fill's side byte to 7 — a
// value no honest matcher emits.
wire := encodeFillsWire([]Fill{
{Price: fp(1), Size: 10, Side: 0},
{Price: fp(1), Size: 5, Side: 0},
})
// Overwrite the second fill's side byte (offset: 4 count + 1*FillWireSize + 16).
wire[4+FillWireSize+16] = 7
fills, err := DecodeFills(wire)
if err == nil {
t.Fatalf("WIRE INJECTION: DecodeFills accepted a fill with side byte 7 "+
"(decoded %d fills) — an out-of-range side is malformed wire and must be "+
"rejected at the boundary, not coerced into a Fill.", len(fills))
}
t.Logf("DecodeFills correctly rejected invalid side byte: %v", err)
// Positive control: the same wire with a VALID side (1=SELL) decodes cleanly,
// so the check rejects only the malformed value, not the whole format.
wire[4+FillWireSize+16] = 1
if _, err := DecodeFills(wire); err != nil {
t.Fatalf("FALSE REJECT: DecodeFills rejected a wire with valid sides {0,1}: %v", err)
}
}
// TestRED_CrashBeforeAccept_DoubleSubmits proves the replay/double-spend window:
// the relay (clob_submit) fires during Verify and the idempotency RECEIPT is
// written only into the versiondb in-memory layer — it is NOT durable until
// Accept calls CommitBatch. A crash between Verify and Accept loses the receipt
// (db.Abort on restart), so re-Verify re-submits the SAME order to the d-chain:
// a SECOND irreversible match. The maker is filled twice; the taker double-pays.
func TestRED_CrashBeforeAccept_DoubleSubmits(t *testing.T) {
fills := []Fill{{Price: fp(1), Size: 1000, Side: 0}}
cvm, matcher, cChainSM, proxyChain, _ := newCountingHarness(t, fills)
ctx := context.Background()
taker := ids.GenerateTestShortID()
asset := ids.GenerateTestID()
srcUTXOID := seedExportedUTXO(t, cChainSM, proxyChain, taker, asset, 1000)
importTx := newImportTxBytes(t, taker, cvm.inner.cChainID(), srcUTXOID, asset, 1000)
relayTx := newRelayTxBytes(t, taker, srcUTXOID, clobSubmitPayload(asset, 1000))
blk := makeBlock(cvm, cvm.lastAcceptedID, 1, time.Unix(1, 0), [][]byte{importTx, relayTx})
// Verify fires the relay (submit #1) and writes the receipt to the versiondb
// in-memory layer.
if err := blk.Verify(ctx); err != nil {
t.Fatalf("verify #1: %v", err)
}
s1, _, _ := matcher.counts()
// Receipt visible in the in-memory layer right now?
_, foundInMem, _ := cvm.inner.state.GetReceipt(deriveBlockHash(1, time.Unix(1, 0)), 1)
// CRASH before Accept: the in-memory versiondb layer is discarded (no
// CommitBatch ever ran). This is exactly what db.Abort() on restart / a
// process kill before Accept does.
cvm.inner.db.Abort()
// Receipt durable after the abort (i.e. did it survive the "crash")?
_, foundAfterCrash, _ := cvm.inner.state.GetReceipt(deriveBlockHash(1, time.Unix(1, 0)), 1)
// Restart: the same block is re-verified (consensus replays unaccepted height).
blk2 := makeBlock(cvm, cvm.lastAcceptedID, 1, time.Unix(1, 0), [][]byte{importTx, relayTx})
if err := blk2.Verify(ctx); err != nil {
t.Fatalf("verify #2 (post-restart): %v", err)
}
s2, _, _ := matcher.counts()
t.Logf("submits after verify#1=%d receipt-in-mem=%v receipt-after-crash=%v submits after re-verify=%d",
s1, foundInMem, foundAfterCrash, s2)
if !foundAfterCrash && s2 > s1 {
t.Fatalf("DOUBLE-SPEND WINDOW: the idempotency receipt was lost on crash "+
"(durable=%v) and re-Verify re-submitted to the d-chain (submits %d -> %d). "+
"The same order matched TWICE on the source-of-truth matcher; the taker's "+
"single locked collateral backs two fills.", foundAfterCrash, s1, s2)
}
}
// TestRED_EscrowTruncation_OverRefunds is the value-EXTRACTION proof, now
// structurally CLOSED by the exact-integer wire. The old settle summed spent as
// Σ uint64(price*size) with a PER-FILL float truncation, so many small fills each
// floored to 0 recorded spent=0 and refunded the whole lock (mint). The wire now
// carries the venue's EXACT integer quote and settleFromFills spends exactly
// Σ floor(size × price / PriceScale) — the SAME per-fill quote the venue moved — so
// spent can never be understated relative to what the taker actually owes the venue.
//
// Concretely: lock 100 quote. Fill 100 base across 100 fills each of size 4 @ price
// 0.25 (fixed-point fp(0.25)); each fill's quote = floor(4 × 0.25) = floor(1.0) = 1,
// so spent = 100 (the venue's exact quote), refund = 0, and value_out = 100 base +
// 0 quote refund — no over-refund. The refund is exactly locked - spent; any refund
// beyond that would be the escrow mint this test forbids.
func TestRED_EscrowTruncation_OverRefunds(t *testing.T) {
// 100 fills of size 4 @ price 0.25 (taker BUY). Per-fill quote = floor(4×0.25)=1,
// so total spent = 100 quote — the exact value the venue moved.
fills := make([]Fill, 100)
for i := range fills {
fills[i] = Fill{Price: fp(0.25), Size: 4, Side: 0}
}
cvm, _, _, _, _ := newCountingHarness(t, fills)
taker := ids.GenerateTestShortID()
collateralRef := ids.GenerateTestID()
lockedAsset := ids.GenerateTestID()
const locked uint64 = 100
// Import recorded escrow of 100 quote under this ref.
if err := cvm.inner.state.PutEscrow(collateralRef, taker, lockedAsset, locked); err != nil {
t.Fatalf("seed escrow: %v", err)
}
ar := newAtomicRequests()
if err := cvm.inner.settleFromFills(taker, collateralRef, fills, ids.GenerateTestID(), 0, false, ids.GenerateTestID(), 0, ar); err != nil {
t.Fatalf("settleFromFills: %v", err)
}
// Sum the exported value by asset: base (proceeds) vs the locked asset (refund).
// Export wire is rail(1)|owner(20)|asset(32)|amount(8), so asset is [21:53].
var refund, base uint64
for _, reqs := range ar.reqs {
for _, e := range reqs.PutRequests {
if len(e.Value) != exportedOutputSize {
continue
}
var a ids.ID
copy(a[:], e.Value[21:53])
amt := binary.BigEndian.Uint64(e.Value[53:61])
if a == lockedAsset {
refund += amt
} else {
base += amt
}
}
}
trueBase := uint64(400) // Σ size = 100 × 4
trueSpent := uint64(100) // Σ floor(4 × 0.25) = 100 × 1
trueRefund := locked - trueSpent
t.Logf("locked=%d base proceeds=%d (true %d) refund=%d (true %d)",
locked, base, trueBase, refund, trueRefund)
// Value extracted = refund beyond what was truly unspent. The taker also keeps
// base proceeds (the bought asset), so any refund > trueRefund is net theft.
if refund > trueRefund {
t.Fatalf("VALUE EXTRACTION: settle refunded %d of the locked asset but only %d "+
"was truly unspent (spent = Σ floor(size×price/PriceScale) = %d). The taker "+
"keeps %d base proceeds too. ~%d quote minted out of the proxy escrow.",
refund, trueRefund, trueSpent, base, refund-trueRefund)
}
}
// exportKeys returns every shared-memory PutRequest key the settle accumulated,
// sorted, so two runs can be compared byte-for-byte. The KEY (not the value) is
// the consensus-critical artifact: atomic.Apply commits these keys into the
// destination chain's shared memory, so if they differ across validators the
// commit diverges and the network splits.
func exportKeys(ar *atomicRequests) [][]byte {
var keys [][]byte
for _, reqs := range ar.reqs {
for _, e := range reqs.PutRequests {
k := make([]byte, len(e.Key))
copy(k, e.Key)
keys = append(keys, k)
}
}
sort.Slice(keys, func(i, j int) bool { return bytes.Compare(keys[i], keys[j]) < 0 })
return keys
}
// TestRED_SettlementExportKeyDeterminism is the regression proof for the
// "export shared-memory key derived from time.Now()" split. It replicates the
// original PoC at the real consensus boundary: TWO independent VM instances
// ("validator A" and "validator B") settle the IDENTICAL fills for the IDENTICAL
// (taker, collateralRef, block coordinate). The exported atomic PutRequest keys
// — which atomic.Apply commits into C-Chain shared memory on accept — MUST be
// byte-identical.
//
// The bug (now closed): settleFromFills built the export via NewExportTx, which
// stamped CreatedAt: time.Now().UnixNano(); finalize() hashed that into the
// TxID; executeExport keyed the PutRequest off deriveUTXOID(tx.ID(), i). Two
// validators stamped different clocks => different TxIDs => different keys =>
// divergent shared-memory commit => consensus split. The fix seeds the export
// identity from the consensus-agreed (blockHash, txIndex) only, so the keys
// match. This test FAILS hard against the time.Now() code and passes with the
// deterministic-seed fix.
func TestRED_SettlementExportKeyDeterminism(t *testing.T) {
fills := []Fill{
{Price: fp(2), Size: 50, Side: 0}, // taker BUY: receives base, pays quote
{Price: fp(2), Size: 50, Side: 0},
}
// The consensus-agreed settlement coordinate every validator sees identically.
blockHash := deriveBlockHash(7, time.Unix(1_700_000_000, 0))
const txIndex = uint32(3)
// Fix the cross-validator-shared inputs so the ONLY thing that could differ is
// the per-node wall clock the bug folded into the key. Co-validators of one
// chain share the SAME C-Chain id (the export's DestinationChain) — pin it so
// the test isolates wall-clock nondeterminism, not a harness-random chain id.
sharedCChainID := ids.GenerateTestID()
taker := ids.GenerateTestShortID()
collateralRef := ids.GenerateTestID()
lockedAsset := ids.GenerateTestID()
// Fixed (NOT random) output asset: the proceeds leg now exports under it, so it is
// part of the export-key preimage; it MUST be identical across the two modeled
// validators or the keys would diverge for a reason other than the wall clock.
tokenOut := ids.GenerateTestID()
const locked uint64 = 1000
settleOn := func(label string) [][]byte {
// A fresh VM instance models a distinct validator. seedEscrow gives the
// refund leg something to conserve so the export carries multiple outputs.
cvm, _, _, _, _ := newCountingHarness(t, fills)
cvm.inner.consensusRuntime.CChainID = sharedCChainID
if err := cvm.inner.state.PutEscrow(collateralRef, taker, lockedAsset, locked); err != nil {
t.Fatalf("%s: seed escrow: %v", label, err)
}
ar := newAtomicRequests()
if err := cvm.inner.settleFromFills(taker, collateralRef, fills, tokenOut, 0, false, blockHash, txIndex, ar); err != nil {
t.Fatalf("%s: settleFromFills: %v", label, err)
}
return exportKeys(ar)
}
// Validator B runs after a real wall-clock advance — the exact condition that
// produced divergent keys under the time.Now() code.
keysA := settleOn("validator A")
time.Sleep(2 * time.Millisecond)
keysB := settleOn("validator B")
if len(keysA) == 0 {
t.Fatal("precondition: settle produced no export keys (test would be vacuous)")
}
if len(keysA) != len(keysB) {
t.Fatalf("export-key COUNT diverged across validators: A=%d B=%d", len(keysA), len(keysB))
}
for i := range keysA {
if !bytes.Equal(keysA[i], keysB[i]) {
t.Fatalf("NONDETERMINISTIC EXPORT KEY (consensus split): for identical fills/ref/block, "+
"validator A export key[%d]=%x but validator B=%x. atomic.Apply commits these into "+
"C-Chain shared memory on accept; divergent keys => divergent commit => the network "+
"forks on the value-settlement path. (The time.Now() in the export TxID is back.)",
i, keysA[i], keysB[i])
}
}
t.Logf("DETERMINISTIC: %d export key(s) byte-identical across two validators despite a wall-clock advance; first=%x",
len(keysA), keysA[0])
}
// TestRED_SettlementExportKeyIndependentOfWallClock pins the mechanism directly:
// the same logical settlement, run twice on ONE VM with a clock advance between,
// yields the same export keys. This isolates the regression to the export
// identity (independent of any cross-instance noise) — if a future change folds
// time.Now() (or any per-call nondeterminism) back into the settlement export's
// TxID, deriveUTXOID flips and this fails.
func TestRED_SettlementExportKeyIndependentOfWallClock(t *testing.T) {
fills := []Fill{{Price: fp(1), Size: 1000, Side: 0}}
blockHash := deriveBlockHash(42, time.Unix(123, 0))
const txIndex = uint32(0)
taker := ids.GenerateTestShortID()
collateralRef := ids.GenerateTestID()
// Fixed output asset: it is part of the export-key preimage, so the two runs must
// use the same value or the keys would differ for a reason other than the clock.
tokenOut := ids.GenerateTestID()
cvm, _, _, _, _ := newCountingHarness(t, fills)
ar1 := newAtomicRequests()
if err := cvm.inner.settleFromFills(taker, collateralRef, fills, tokenOut, 0, false, blockHash, txIndex, ar1); err != nil {
t.Fatalf("settle #1: %v", err)
}
time.Sleep(2 * time.Millisecond)
ar2 := newAtomicRequests()
if err := cvm.inner.settleFromFills(taker, collateralRef, fills, tokenOut, 0, false, blockHash, txIndex, ar2); err != nil {
t.Fatalf("settle #2: %v", err)
}
k1, k2 := exportKeys(ar1), exportKeys(ar2)
if len(k1) == 0 || len(k1) != len(k2) {
t.Fatalf("export-key count mismatch/empty: #1=%d #2=%d", len(k1), len(k2))
}
for i := range k1 {
if !bytes.Equal(k1[i], k2[i]) {
t.Fatalf("WALL-CLOCK LEAK INTO EXPORT KEY: same (blockHash, txIndex, fills) produced "+
"key[%d] %x then %x across a clock advance — the settlement export identity is "+
"not a pure function of consensus inputs.", i, k1[i], k2[i])
}
}
t.Logf("export key stable across wall-clock advance: %x", k1[0])
}
-343
View File
@@ -1,343 +0,0 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//go:build redteam
package dexvm
import (
"context"
"encoding/binary"
"encoding/json"
"testing"
"time"
"github.com/luxfi/crypto/secp256k1"
"github.com/luxfi/ids"
"github.com/luxfi/chains/dexvm/txs"
)
// swapRelayFromField re-encodes a relay's wire bytes (type(1)|JSON) with ONLY the
// "from" field changed to newFrom, leaving the signature and every other field
// byte-identical — so a Verify failure isolates the From axis.
func swapRelayFromField(t *testing.T, wire []byte, newFrom ids.ShortID) []byte {
t.Helper()
var m map[string]json.RawMessage
if err := json.Unmarshal(wire[1:], &m); err != nil {
t.Fatalf("unmarshal relay body: %v", err)
}
fromJSON, err := newFrom.MarshalJSON()
if err != nil {
t.Fatalf("marshal from: %v", err)
}
m["from"] = fromJSON
body, err := json.Marshal(m)
if err != nil {
t.Fatalf("remarshal relay body: %v", err)
}
return append([]byte{wire[0]}, body...)
}
// redteam_escrow_owner_test.go is the CRITICAL escrow-theft RED suite. It proves
// the FIXED ship rule for the swap rail's value-return leg:
//
// the collateral an import locks is owned by the AUTHENTICATED owner recorded on
// the consumed C->D object; ONLY that owner may settle it, and the proceeds +
// refund are exported to THAT owner — never to the relay tx's (unauthenticated)
// sender.
//
// The pre-fix exploit (proven CLOSED here): the escrow stored only (asset, amount)
// with NO owner, and settleFromFills exported both legs to `taker = r.sender` (the
// RelayOrderTx sender). RelayOrderTx.Verify authenticated nothing, so any fee-paying
// tx could name a VICTIM's collateral ref with an attacker From; a ~zero fill made
// refund == locked, exported to the attacker out of the SHARED seam reserve =
// cross-depositor theft. Now the escrow records the importer's owner and settle
// binds BOTH authority and payout to it.
// TestRED_Escrow_SettleBoundToImporterOwner is the END-TO-END theft proof on the
// real import -> relay -> settle path. A victim imports collateral; an ATTACKER
// submits a RelayOrderTx naming the victim's collateral ref with the attacker as
// From. At settle (proposer build+accept), the refund/proceeds MUST go to the
// victim (the recorded escrow owner) and the attacker MUST receive nothing.
func TestRED_Escrow_SettleBoundToImporterOwner(t *testing.T) {
// Zero fills => the whole locked collateral is a refund (the cleanest theft
// vector: attacker tries to walk away with the full lock).
cvm, matcher, cChainSM, proxyChain, _ := newCountingHarness(t, nil)
ctx := context.Background()
victim := ids.GenerateTestShortID()
attacker := ids.GenerateTestShortID()
asset := ids.GenerateTestID()
const locked uint64 = 1000
// The VICTIM imports 1000 of `asset` (the C-Chain exported it to the proxy for
// the victim; the import binds the escrow owner = victim from the recorded UTXO).
srcUTXOID := seedExportedUTXO(t, cChainSM, proxyChain, victim, asset, locked)
importTx := newImportTxBytes(t, victim, cvm.inner.cChainID(), srcUTXOID, asset, locked)
// The ATTACKER submits a settling relay (clob_submit) naming the VICTIM's
// collateral ref, with the attacker as the tx sender (From). Pre-fix this would
// export the refund to the attacker; post-fix the settle is bound to the escrow
// owner (victim) and refuses/redirects.
attackerRelay := newRelayTxBytes(t, attacker, srcUTXOID, clobSubmitPayload(asset, locked))
// Proposer builds (relays once; matcher returns ZERO fills) + accepts (settle).
cvm.inner.clock.Set(time.Unix(1, 0))
cvm.pendingTxs = [][]byte{importTx, attackerRelay}
built, err := cvm.BuildBlock(ctx)
if err != nil {
t.Fatalf("BuildBlock: %v", err)
}
if err := cvm.SetPreference(ctx, built.ID()); err != nil {
t.Fatalf("SetPreference: %v", err)
}
if err := built.Verify(ctx); err != nil {
t.Fatalf("verify: %v", err)
}
if err := built.Accept(ctx); err != nil {
t.Fatalf("accept: %v", err)
}
attackerCredited := creditedTo(t, cChainSM, proxyChain, attacker)
victimCredited := creditedTo(t, cChainSM, proxyChain, victim)
t.Logf("AFTER ATTACKER-RELAY SETTLE: attacker credited=%d victim credited=%d (locked=%d)",
attackerCredited, victimCredited, locked)
// THE THEFT IS CLOSED: the attacker (the relay sender, NOT the escrow owner)
// receives NOTHING. The collateral never leaves the victim's ownership.
if attackerCredited != 0 {
t.Fatalf("ESCROW THEFT: the attacker (relay sender, not the escrow owner) was credited %d "+
"out of the victim's locked collateral — settle authority + payout MUST bind to the "+
"recorded escrow owner, never the unauthenticated relay sender.", attackerCredited)
}
// The escrow is left intact for the rightful owner (the unauthorized settle is
// refused before it consumes the escrow), so the victim's value is recoverable.
_, _, _, found, eerr := cvm.inner.state.GetEscrow(srcUTXOID)
if eerr != nil {
t.Fatalf("escrow lookup: %v", eerr)
}
if !found {
t.Fatalf("ESCROW THEFT: the unauthorized attacker settle CONSUMED the victim's escrow — a " +
"refused settle must leave the escrow intact and recoverable by the rightful owner.")
}
if submits, _, _ := matcher.counts(); submits != 1 {
t.Fatalf("expected exactly one proposer relay submit, got %d", submits)
}
}
// TestRED_Escrow_OwnerMaySettleAndIsPaid is the POSITIVE control: when the
// RIGHTFUL owner (the importer) submits the settling relay, the settle succeeds and
// the owner is paid the full refund. The bind is authority, not a blanket denial.
func TestRED_Escrow_OwnerMaySettleAndIsPaid(t *testing.T) {
cvm, matcher, cChainSM, proxyChain, _ := newCountingHarness(t, nil) // zero fills => full refund
ctx := context.Background()
owner := ids.GenerateTestShortID()
asset := ids.GenerateTestID()
const locked uint64 = 1000
srcUTXOID := seedExportedUTXO(t, cChainSM, proxyChain, owner, asset, locked)
importTx := newImportTxBytes(t, owner, cvm.inner.cChainID(), srcUTXOID, asset, locked)
ownerRelay := newRelayTxBytes(t, owner, srcUTXOID, clobSubmitPayload(asset, locked))
cvm.inner.clock.Set(time.Unix(1, 0))
cvm.pendingTxs = [][]byte{importTx, ownerRelay}
built, err := cvm.BuildBlock(ctx)
if err != nil {
t.Fatalf("BuildBlock: %v", err)
}
if err := cvm.SetPreference(ctx, built.ID()); err != nil {
t.Fatalf("SetPreference: %v", err)
}
if err := built.Verify(ctx); err != nil {
t.Fatalf("verify: %v", err)
}
if err := built.Accept(ctx); err != nil {
t.Fatalf("accept: %v", err)
}
credited := creditedTo(t, cChainSM, proxyChain, owner)
t.Logf("AFTER RIGHTFUL-OWNER SETTLE: owner credited=%d (locked=%d)", credited, locked)
if credited != locked {
t.Fatalf("the rightful escrow owner's settle must refund the full locked %d, got %d", locked, credited)
}
// The escrow is consumed exactly once by the legitimate settle.
if _, _, _, found, _ := cvm.inner.state.GetEscrow(srcUTXOID); found {
t.Fatalf("a completed legitimate settle must consume the escrow exactly once (still present)")
}
if submits, _, _ := matcher.counts(); submits != 1 {
t.Fatalf("expected exactly one proposer relay submit, got %d", submits)
}
}
// TestRED_Escrow_DirectSettleRejectsForeignSender pins the authority bind at the
// unit level (independent of the build path): settleFromFills with a sender that is
// NOT the recorded escrow owner returns errSettleUnauthorized and moves no value,
// leaving the escrow intact.
func TestRED_Escrow_DirectSettleRejectsForeignSender(t *testing.T) {
cvm, _, _, _, _ := newCountingHarness(t, nil)
owner := ids.GenerateTestShortID()
foreign := ids.GenerateTestShortID()
ref := deriveUTXOID(ids.GenerateTestID(), 0)
asset := ids.GenerateTestID()
const locked uint64 = 500
if err := cvm.inner.state.PutEscrow(ref, owner, asset, locked); err != nil {
t.Fatalf("PutEscrow: %v", err)
}
// A foreign sender (not the escrow owner) attempts to settle => refused.
ar := newAtomicRequests()
err := cvm.inner.settleFromFills(foreign, ref, nil, ids.Empty, 0, false, ids.GenerateTestID(), 0, ar)
if err != errSettleUnauthorized {
t.Fatalf("a settle by a non-owner sender MUST return errSettleUnauthorized, got: %v", err)
}
if !ar.empty() {
t.Fatalf("a refused (unauthorized) settle must export nothing")
}
// The escrow is intact — the rightful owner can still settle it.
if _, _, _, found, _ := cvm.inner.state.GetEscrow(ref); !found {
t.Fatalf("a refused unauthorized settle must leave the escrow intact")
}
// The rightful owner then settles successfully (zero fills => full refund).
ar2 := newAtomicRequests()
if err := cvm.inner.settleFromFills(owner, ref, nil, ids.Empty, 0, false, ids.GenerateTestID(), 1, ar2); err != nil {
t.Fatalf("the rightful owner's settle must succeed, got: %v", err)
}
var refundToOwner uint64
for _, reqs := range ar2.reqs {
for _, e := range reqs.PutRequests {
if len(e.Value) >= exportedOutputSize {
var o ids.ShortID
copy(o[:], e.Value[1:21]) // export wire: rail(1)|owner(20)|asset(32)|amount(8)
if o == owner {
refundToOwner += binary.BigEndian.Uint64(e.Value[53:61])
}
}
}
}
if refundToOwner != locked {
t.Fatalf("the rightful owner must be refunded the full locked %d, got %d", locked, refundToOwner)
}
}
// --- Signature-authentication of RelayOrderTx.From (provenance hardening) -------
// TestRED_Relay_SignatureAuthenticatesFrom proves the present-gated secp256k1 bind
// on RelayOrderTx.From: a tx signed by `from`'s key Verifies; the SAME tx with From
// swapped to a victim (a spoofed-From relay carrying someone else's signature) is
// REFUSED at admission with ErrInvalidSignature; an unsigned tx is permitted (From
// carries no authority — the escrow bind is the authority).
func TestRED_Relay_SignatureAuthenticatesFrom(t *testing.T) {
key, err := secp256k1.NewPrivateKey()
if err != nil {
t.Fatalf("keygen: %v", err)
}
ref := deriveUTXOID(ids.GenerateTestID(), 0)
// (a) A signed relay Verifies and stamps From = the signer's EVM address.
signed := txs.NewRelayOrderTx(ids.ShortEmpty, 0, ZAPMethodSubmit, []byte{0x01}, ref)
if err := signed.Sign(key); err != nil {
t.Fatalf("sign: %v", err)
}
if ids.ShortID(key.EVMAddress()) != signed.Sender() {
t.Fatalf("Sign must set From to the signer's EVM address")
}
if err := signed.Verify(); err != nil {
t.Fatalf("a correctly-signed relay must Verify, got: %v", err)
}
// The signed tx must survive a wire round-trip and still Verify (the signature
// commits to the unsigned image, not to itself).
parser := &txs.TxParser{}
reparsed, perr := parser.Parse(signed.Bytes())
if perr != nil {
t.Fatalf("reparse signed relay: %v", perr)
}
if err := reparsed.Verify(); err != nil {
t.Fatalf("a reparsed signed relay must Verify, got: %v", err)
}
// (b) SPOOFED From: re-encode the EXACT signed wire image with ONLY From swapped to
// a victim, then reparse + Verify. The signature still recovers to the original
// signer, not to the (victim) From, so the bind refuses it => ErrInvalidSignature.
// (Mutating the parsed JSON's "from" field isolates the spoof to the From axis.)
victim := ids.GenerateTestShortID()
spoofWire := swapRelayFromField(t, signed.Bytes(), victim)
spoof, perr2 := parser.Parse(spoofWire)
if perr2 != nil {
t.Fatalf("parse spoof wire: %v", perr2)
}
if err := spoof.Verify(); err != txs.ErrInvalidSignature {
t.Fatalf("a spoofed-From relay (someone else's signature) MUST revert ErrInvalidSignature, got: %v", err)
}
// (c) UNSIGNED relay is permitted at admission (From has no authority; the escrow
// owner bind is what governs settlement — see TestRED_Escrow_*).
unsigned := txs.NewRelayOrderTx(victim, 0, ZAPMethodSubmit, []byte{0x01}, ref)
if err := unsigned.Verify(); err != nil {
t.Fatalf("an unsigned relay must be admitted (authority is the escrow bind), got: %v", err)
}
}
// TestRED_Escrow_SignedAttackerStillCannotSteal is the defense-in-depth proof: even
// a VALID signature (the attacker signs with their OWN key, so From is authentic) does
// NOT let the attacker settle a victim's collateral — because the settle authority +
// payout bind to the escrow's recorded owner (the victim), not to the authenticated
// relay sender. Layer 1 (escrow owner) holds independently of Layer 2 (signature).
func TestRED_Escrow_SignedAttackerStillCannotSteal(t *testing.T) {
cvm, _, cChainSM, proxyChain, _ := newCountingHarness(t, nil)
ctx := context.Background()
attackerKey, err := secp256k1.NewPrivateKey()
if err != nil {
t.Fatalf("keygen: %v", err)
}
attacker := ids.ShortID(attackerKey.EVMAddress())
victim := ids.GenerateTestShortID()
asset := ids.GenerateTestID()
const locked uint64 = 1000
srcUTXOID := seedExportedUTXO(t, cChainSM, proxyChain, victim, asset, locked)
importTx := newImportTxBytes(t, victim, cvm.inner.cChainID(), srcUTXOID, asset, locked)
// The attacker signs a settling relay with their OWN key (so the signature is
// VALID and From == attacker), naming the victim's collateral ref.
relay := txs.NewRelayOrderTx(ids.ShortEmpty, 0, ZAPMethodSubmit, clobSubmitPayload(asset, locked), srcUTXOID)
if err := relay.Sign(attackerKey); err != nil {
t.Fatalf("attacker sign: %v", err)
}
if err := relay.Verify(); err != nil {
t.Fatalf("attacker's own-key signature must itself Verify (it is authentic), got: %v", err)
}
cvm.inner.clock.Set(time.Unix(1, 0))
cvm.pendingTxs = [][]byte{importTx, relay.Bytes()}
built, err := cvm.BuildBlock(ctx)
if err != nil {
t.Fatalf("BuildBlock: %v", err)
}
if err := cvm.SetPreference(ctx, built.ID()); err != nil {
t.Fatalf("SetPreference: %v", err)
}
if err := built.Verify(ctx); err != nil {
t.Fatalf("verify: %v", err)
}
if err := built.Accept(ctx); err != nil {
t.Fatalf("accept: %v", err)
}
attackerCredited := creditedTo(t, cChainSM, proxyChain, attacker)
t.Logf("AFTER SIGNED-ATTACKER SETTLE: attacker credited=%d (locked=%d)", attackerCredited, locked)
if attackerCredited != 0 {
t.Fatalf("DEFENSE-IN-DEPTH BROKEN: an attacker with a VALID signature (authentic From) was "+
"credited %d of the victim's collateral — settle authority MUST be the escrow's recorded "+
"owner (the victim), independent of who signed the relay.", attackerCredited)
}
// Escrow intact (unauthorized settle refused before consuming it).
if _, _, _, found, _ := cvm.inner.state.GetEscrow(srcUTXOID); !found {
t.Fatalf("a refused signed-attacker settle must leave the victim's escrow intact")
}
}
-242
View File
@@ -1,242 +0,0 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//go:build redteam
// redteam_fill_attestation_test.go — the MEDIUM single-proposer fabricated-fills proof.
// A malicious/MITM proposer can carry FABRICATED fills (carried_fills.go). The fix: the
// venue (d-chain matcher) ATTESTS the fills with an Ed25519 signature over the canonical
// (blockHash, entries) message, and every validator verifies it before settling. These
// tests prove:
//
// (1) enforcement OFF (no venue pubkey): carried fills settle as before (interim model);
// (2) enforcement ON + VALID attestation: the attested fills settle normally;
// (3) enforcement ON + NO/INVALID attestation: the (possibly fabricated) fills are
// DISTRUSTED — every submit is refunded in full, no fabricated proceeds move.
package dexvm
import (
"context"
"testing"
"time"
"github.com/luxfi/crypto/ed25519"
"github.com/luxfi/ids"
)
// TestRED_FillAttestation_VerifyRejectsForgedFills is the unit-level proof of the
// verify gate over the canonical message: a correct venue signature verifies; a missing
// one, a wrong-key one, and a tampered-entries one are all rejected when a pubkey is
// configured; and with NO pubkey enforcement is off (any/no sig passes).
func TestRED_FillAttestation_VerifyRejectsForgedFills(t *testing.T) {
pub, priv, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatalf("keygen: %v", err)
}
blockHash := ids.GenerateTestID()
entries := []carriedFill{{txIndex: 0, fills: []Fill{{Price: fp(2), Size: 100, Side: 0}}}}
sig := signFillAttestation(priv, blockHash, entries)
if len(sig) == 0 {
t.Fatalf("signFillAttestation produced no signature")
}
// Valid signature, enforcement on => accepted.
if err := verifyFillAttestation(pub, sig, blockHash, entries); err != nil {
t.Fatalf("a valid venue attestation must verify, got: %v", err)
}
// Missing signature, enforcement on => required.
if err := verifyFillAttestation(pub, nil, blockHash, entries); err != ErrFillAttestationRequired {
t.Fatalf("a missing attestation under enforcement must be ErrFillAttestationRequired, got: %v", err)
}
// Wrong key => invalid (an attacker cannot forge the venue's signature).
otherPub, _, _ := ed25519.GenerateKey(nil)
if err := verifyFillAttestation(otherPub, sig, blockHash, entries); err != ErrFillAttestationInvalid {
t.Fatalf("a signature under a different key must be ErrFillAttestationInvalid, got: %v", err)
}
// TAMPERED ENTRIES (the fabrication): the proposer keeps the venue's signature but
// swaps in fabricated fills. The message changes => the signature no longer verifies.
forged := []carriedFill{{txIndex: 0, fills: []Fill{{Price: fp(2), Size: 1_000_000, Side: 0}}}}
if err := verifyFillAttestation(pub, sig, blockHash, forged); err != ErrFillAttestationInvalid {
t.Fatalf("a fabricated fill set under the genuine signature must be ErrFillAttestationInvalid, got: %v", err)
}
// DIFFERENT BLOCK (replay): the same fills+sig under a different block hash fail —
// the message binds the block hash, so a venue signature is not replayable.
if err := verifyFillAttestation(pub, sig, ids.GenerateTestID(), entries); err != ErrFillAttestationInvalid {
t.Fatalf("a venue signature replayed onto another block must be ErrFillAttestationInvalid, got: %v", err)
}
// No pubkey configured => enforcement off => nil regardless of sig.
if err := verifyFillAttestation(nil, nil, blockHash, entries); err != nil {
t.Fatalf("with no venue pubkey, enforcement is off and verify must return nil, got: %v", err)
}
// Misconfigured (wrong-width) pubkey must FAIL CLOSED, never silently disable.
if err := verifyFillAttestation([]byte{1, 2, 3}, sig, blockHash, entries); err != ErrFillAttestationInvalid {
t.Fatalf("a malformed pubkey must fail closed (ErrFillAttestationInvalid), got: %v", err)
}
}
// TestRED_FillAttestation_EndToEnd_ValidAttestationSettles is the positive control: a
// proposer co-located with the venue (holds the seed) signs the carried fills, and with
// the matching pubkey configured the attested cross-asset fills settle normally — the
// taker receives the real output proceeds.
func TestRED_FillAttestation_EndToEnd_ValidAttestationSettles(t *testing.T) {
// One key pair shared by the (co-located) proposer-signer and the validators: the
// proposer signs with `seed`, the validators verify with the derived pubkey.
seed := make([]byte, ed25519.SeedSize)
for i := range seed {
seed[i] = byte(i + 1)
}
priv, err := ed25519.NewKeyFromSeed(seed)
if err != nil {
t.Fatalf("derive key from seed: %v", err)
}
pubFromSeed := priv.Public().(ed25519.PublicKey)
fills := []Fill{{Price: fp(2), Size: 150, Side: 0}}
cvm, matcher, cChainSM, proxyChain, _ := newCountingHarness(t, fills)
// Enforcement ON with the matching key pair (proposer signs with seed; validators
// verify with the derived pubkey).
cvm.inner.Config.FillAttestationSeed = seed
cvm.inner.Config.FillAttestationPubKey = pubFromSeed
ctx := context.Background()
taker := ids.GenerateTestShortID()
quoteAsset := ids.GenerateTestID()
baseAsset := ids.GenerateTestID()
const lockedQuote uint64 = 1000
srcUTXOID := seedExportedUTXO(t, cChainSM, proxyChain, taker, quoteAsset, lockedQuote)
importTx := newImportTxBytes(t, taker, cvm.inner.cChainID(), srcUTXOID, quoteAsset, lockedQuote)
relayTx := newSettlingRelayTxBytes(t, taker, srcUTXOID, baseAsset, clobSubmitPayload(quoteAsset, lockedQuote), 0, false)
cvm.inner.clock.Set(time.Unix(1, 0))
cvm.pendingTxs = [][]byte{importTx, relayTx}
built, err := cvm.BuildBlock(ctx)
if err != nil {
t.Fatalf("BuildBlock: %v", err)
}
if err := cvm.SetPreference(ctx, built.ID()); err != nil {
t.Fatalf("SetPreference: %v", err)
}
if err := built.Verify(ctx); err != nil {
t.Fatalf("verify: %v", err)
}
if err := built.Accept(ctx); err != nil {
t.Fatalf("accept: %v", err)
}
legs := exportedLegsByAsset(t, cChainSM, proxyChain, taker)
if legs[baseAsset] != 150 {
t.Fatalf("attested fills must settle: taker base proceeds=%d, want 150 (legs=%v)", legs[baseAsset], legs)
}
if submits, _, _ := matcher.counts(); submits != 1 {
t.Fatalf("expected exactly one proposer relay submit, got %d", submits)
}
t.Logf("ATTESTATION OK: venue-signed cross-asset fill settled (150 base proceeds + %d quote refund)", legs[quoteAsset])
}
// TestRED_FillAttestation_FabricatedFillsRefunded is the decisive fabricated-fills
// proof. Enforcement is ON (a venue pubkey is configured) but the proposer holds NO
// signing seed, so the block it builds carries NO attestation — modeling a proposer
// that fabricates fills it cannot get the venue to sign. Every settle is then a FULL
// REFUND: the taker's locked collateral returns to C and NO fabricated proceeds move.
func TestRED_FillAttestation_FabricatedFillsRefunded(t *testing.T) {
pub, _, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatalf("keygen: %v", err)
}
// A proposer carries a FABRICATED fill (200 base) but cannot attest it (no seed).
fills := []Fill{{Price: fp(2), Size: 200, Side: 0}}
cvm, _, cChainSM, proxyChain, _ := newCountingHarness(t, fills)
cvm.inner.Config.FillAttestationPubKey = pub // enforcement ON
// NO FillAttestationSeed => the proposer's block carries an empty signature.
ctx := context.Background()
taker := ids.GenerateTestShortID()
quoteAsset := ids.GenerateTestID()
baseAsset := ids.GenerateTestID()
const lockedQuote uint64 = 1000
srcUTXOID := seedExportedUTXO(t, cChainSM, proxyChain, taker, quoteAsset, lockedQuote)
importTx := newImportTxBytes(t, taker, cvm.inner.cChainID(), srcUTXOID, quoteAsset, lockedQuote)
relayTx := newSettlingRelayTxBytes(t, taker, srcUTXOID, baseAsset, clobSubmitPayload(quoteAsset, lockedQuote), 0, false)
cvm.inner.clock.Set(time.Unix(1, 0))
cvm.pendingTxs = [][]byte{importTx, relayTx}
built, err := cvm.BuildBlock(ctx)
if err != nil {
t.Fatalf("BuildBlock: %v", err)
}
if err := cvm.SetPreference(ctx, built.ID()); err != nil {
t.Fatalf("SetPreference: %v", err)
}
if err := built.Verify(ctx); err != nil {
t.Fatalf("verify: %v", err)
}
if err := built.Accept(ctx); err != nil {
t.Fatalf("accept: %v", err)
}
legs := exportedLegsByAsset(t, cChainSM, proxyChain, taker)
t.Logf("AFTER UNATTESTED (fabricated) BLOCK: base proceeds=%d quote refund=%d", legs[baseAsset], legs[quoteAsset])
// NO fabricated proceeds moved: the base proceeds leg is absent (the unattested fills
// were distrusted), and the taker is made whole by a FULL quote refund.
if legs[baseAsset] != 0 {
t.Fatalf("FABRICATED FILL SETTLED: %d base proceeds moved despite a missing venue attestation — a "+
"lying proposer minted proceeds for fills the venue never confirmed.", legs[baseAsset])
}
if legs[quoteAsset] != lockedQuote {
t.Fatalf("fail-secure refund broken: quote refund=%d, want the full locked %d (an unattested block must "+
"refund the taker, not strand or partially settle).", legs[quoteAsset], lockedQuote)
}
}
// TestRED_FillAttestation_OffByDefaultSettlesInterim pins that with NO venue pubkey
// (the single-trusted-operator / dev default) enforcement is off and carried fills
// settle exactly as the interim model — so the new gate is strictly opt-in and does not
// regress existing deployments.
func TestRED_FillAttestation_OffByDefaultSettlesInterim(t *testing.T) {
fills := []Fill{{Price: fp(2), Size: 100, Side: 0}}
cvm, _, cChainSM, proxyChain, _ := newCountingHarness(t, fills)
// Default config: no FillAttestationPubKey => enforcement off.
ctx := context.Background()
taker := ids.GenerateTestShortID()
quoteAsset := ids.GenerateTestID()
baseAsset := ids.GenerateTestID()
const lockedQuote uint64 = 1000
srcUTXOID := seedExportedUTXO(t, cChainSM, proxyChain, taker, quoteAsset, lockedQuote)
importTx := newImportTxBytes(t, taker, cvm.inner.cChainID(), srcUTXOID, quoteAsset, lockedQuote)
relayTx := newSettlingRelayTxBytes(t, taker, srcUTXOID, baseAsset, clobSubmitPayload(quoteAsset, lockedQuote), 0, false)
cvm.inner.clock.Set(time.Unix(1, 0))
cvm.pendingTxs = [][]byte{importTx, relayTx}
built, err := cvm.BuildBlock(ctx)
if err != nil {
t.Fatalf("BuildBlock: %v", err)
}
if err := cvm.SetPreference(ctx, built.ID()); err != nil {
t.Fatalf("SetPreference: %v", err)
}
if err := built.Verify(ctx); err != nil {
t.Fatalf("verify: %v", err)
}
if err := built.Accept(ctx); err != nil {
t.Fatalf("accept: %v", err)
}
legs := exportedLegsByAsset(t, cChainSM, proxyChain, taker)
if legs[baseAsset] != 100 {
t.Fatalf("with enforcement off, carried fills must settle as before: base proceeds=%d, want 100", legs[baseAsset])
}
}
-248
View File
@@ -1,248 +0,0 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//go:build redteam
// redteam_genesis_attestation_test.go — the HIGH proof that the fill-attestation gate is
// REACHABLE IN PRODUCTION, not merely correct in isolation.
//
// The sibling redteam_fill_attestation_test.go drives enforcement by SETTING
// cvm.inner.Config.FillAttestationPubKey directly. That proves the gate LOGIC but NOT the
// production wiring: a real validator never hand-edits Config — it gets its settings from
// the genesis/config bytes the node hands Initialize(). The HIGH finding was exactly that
// FillAttestationPubKey was NEVER written from genesis (parseConfig copies only enumerated
// fields; parseGenesis carried only DexZapEndpoint+TrustedChains), so in prod the pubkey
// was nil -> verifyFillAttestation returned nil -> trustCarried always true -> the
// fabricated-fills guard could NEVER engage, and a malicious/MITM proposer could settle
// fabricated fills and drain a cross-taker's seam reserve.
//
// These tests drive a REAL genesis blob through Initialize -> parseGenesis (NOT a direct
// Config write) and assert:
//
// (1) the genesis-pinned pubkey lands in Config (the plumbing exists end-to-end);
// (2) with it pinned, a FABRICATED (unattested) block becomes a FULL REFUND — enforcement
// is on by virtue of GENESIS ALONE, no per-node Config touch;
// (3) a genesis pubkey of the wrong width is a FATAL Initialize error (fail-closed — a
// garbage key must never silently degrade to "no enforcement");
// (4) the pubkey is DELIBERATELY NOT settable via the runtime config blob (it is
// consensus-affecting and must be a single genesis-pinned constant — a per-node config
// value would fork the StateRoot).
package dexvm
import (
"context"
"encoding/hex"
"encoding/json"
"testing"
"time"
"github.com/luxfi/chains/dexvm/config"
"github.com/luxfi/crypto/ed25519"
"github.com/luxfi/database/memdb"
"github.com/luxfi/database/prefixdb"
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/runtime"
"github.com/luxfi/vm"
"github.com/luxfi/vm/chains/atomic"
"github.com/luxfi/warp"
)
// newGenesisHarness is newCountingHarness's production-faithful sibling: it hands the VM a
// real genesis JSON (and optional config JSON) blob through Initialize so the settings flow
// through the SAME parseGenesis/parseConfig path a node uses — never a direct Config write.
// It returns initErr instead of failing so a test can assert a fail-closed Initialize.
func newGenesisHarness(t *testing.T, fills []Fill, genesisJSON, configJSON []byte) (*ChainVM, atomic.SharedMemory, ids.ID, ids.ID, error) {
t.Helper()
logger := log.NewNoOpLogger()
baseDB := memdb.New()
memoryDB := prefixdb.New([]byte{0}, baseDB)
m := atomic.NewMemory(memoryDB)
proxyChain := ids.GenerateTestID()
cChain := ids.GenerateTestID()
proxySM := m.NewSharedMemory(proxyChain)
cChainSM := m.NewSharedMemory(cChain)
matcher := &countingMatcher{fills: fills}
prev := zapDialer
zapDialer = func(_ context.Context, _ string) (zapConn, error) { return matcher, nil }
t.Cleanup(func() { zapDialer = prev })
rt := &runtime.Runtime{
ChainID: proxyChain,
CChainID: cChain,
NetworkID: 96369,
Log: logger,
SharedMemory: proxySM,
}
cvm := NewChainVM(logger)
// Start from the real default config (NOT a hand-set pubkey) so the ONLY way enforcement
// turns on is the genesis blob below — the production path under test.
cvm.inner.Config = config.DefaultConfig()
cvm.inner.Config.DexZapEndpoint = "127.0.0.1:0"
initErr := cvm.Initialize(context.Background(), vm.Init{
Runtime: rt,
DB: prefixdb.New([]byte{1}, baseDB),
ToEngine: make(chan vm.Message, 8),
Sender: warp.FakeSender{},
Log: logger,
Genesis: genesisJSON,
Config: configJSON,
})
if initErr == nil {
cvm.inner.bootstrapped = true
}
return cvm, cChainSM, proxyChain, cChain, initErr
}
// genesisWithPubKey builds the network genesis blob a node would ship, pinning the venue's
// Ed25519 attestation pubkey (hex) — the single network-wide constant that turns
// enforcement on for an untrusted validator set.
func genesisWithPubKey(pub ed25519.PublicKey) []byte {
g := struct {
FillAttestationPubKey string `json:"fillAttestationPubKey"`
}{FillAttestationPubKey: hex.EncodeToString(pub)}
b, _ := json.Marshal(g)
return b
}
// TestRED_GenesisAttestation_PubKeyReachesConfig proves the plumbing the HIGH finding said
// was missing: a genesis blob carrying fillAttestationPubKey actually lands in Config after
// Initialize, so verifyFillAttestation can see it. Before the fix this asserted-on field
// was always nil because nothing copied it out of genesis.
func TestRED_GenesisAttestation_PubKeyReachesConfig(t *testing.T) {
pub, _, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatalf("keygen: %v", err)
}
cvm, _, _, _, initErr := newGenesisHarness(t, nil, genesisWithPubKey(pub), nil)
if initErr != nil {
t.Fatalf("Initialize with a valid genesis pubkey must succeed, got: %v", initErr)
}
got := cvm.inner.Config.FillAttestationPubKey
if len(got) != ed25519.PublicKeySize {
t.Fatalf("genesis-pinned pubkey did not reach Config: got %d bytes, want %d "+
"(the HIGH inert-gate bug — fillAttestationPubKey never plumbed through genesis)",
len(got), ed25519.PublicKeySize)
}
if string(got) != string(pub) {
t.Fatalf("genesis-pinned pubkey mismatch: Config holds a different key than the genesis blob")
}
// And the gate is now LIVE: verify rejects a missing signature (enforcement on).
bh := ids.GenerateTestID()
entries := []carriedFill{{txIndex: 0, fills: []Fill{{Price: fp(2), Size: 100, Side: 0}}}}
if verr := verifyFillAttestation(cvm.inner.Config.FillAttestationPubKey, nil, bh, entries); verr != ErrFillAttestationRequired {
t.Fatalf("with a genesis-pinned pubkey the gate must REQUIRE a signature, got: %v", verr)
}
t.Logf("PLUMBING OK: genesis fillAttestationPubKey -> Config -> enforcement live")
}
// TestRED_GenesisAttestation_FabricatedBlockFullRefund is the decisive end-to-end proof:
// enforcement is turned on SOLELY by the genesis blob (no Config touch), and a proposer that
// carries FABRICATED fills it cannot attest (it holds no signing seed) produces a block whose
// every settle is a FULL REFUND — no fabricated proceeds move, so the cross-taker
// seam-reserve drain the HIGH finding describes is impossible.
func TestRED_GenesisAttestation_FabricatedBlockFullRefund(t *testing.T) {
pub, _, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatalf("keygen: %v", err)
}
// A proposer carries a FABRICATED fill (200 base) but cannot attest it (no seed in any
// config). Enforcement is on ONLY because the genesis blob pinned the pubkey.
fills := []Fill{{Price: fp(2), Size: 200, Side: 0}}
cvm, cChainSM, proxyChain, _, initErr := newGenesisHarness(t, fills, genesisWithPubKey(pub), nil)
if initErr != nil {
t.Fatalf("Initialize: %v", initErr)
}
// Belt-and-suspenders: prove we did NOT hand-set the pubkey — it came from genesis.
if len(cvm.inner.Config.FillAttestationPubKey) != ed25519.PublicKeySize {
t.Fatalf("precondition: enforcement must be on via genesis, but Config pubkey is unset")
}
if len(cvm.inner.Config.FillAttestationSeed) != 0 {
t.Fatalf("precondition: the fabricating proposer must hold NO signing seed")
}
ctx := context.Background()
taker := ids.GenerateTestShortID()
quoteAsset := ids.GenerateTestID()
baseAsset := ids.GenerateTestID()
const lockedQuote uint64 = 1000
srcUTXOID := seedExportedUTXO(t, cChainSM, proxyChain, taker, quoteAsset, lockedQuote)
importTx := newImportTxBytes(t, taker, cvm.inner.cChainID(), srcUTXOID, quoteAsset, lockedQuote)
relayTx := newSettlingRelayTxBytes(t, taker, srcUTXOID, baseAsset, clobSubmitPayload(quoteAsset, lockedQuote), 0, false)
cvm.inner.clock.Set(time.Unix(1, 0))
cvm.pendingTxs = [][]byte{importTx, relayTx}
built, err := cvm.BuildBlock(ctx)
if err != nil {
t.Fatalf("BuildBlock: %v", err)
}
if err := cvm.SetPreference(ctx, built.ID()); err != nil {
t.Fatalf("SetPreference: %v", err)
}
if err := built.Verify(ctx); err != nil {
t.Fatalf("verify: %v", err)
}
if err := built.Accept(ctx); err != nil {
t.Fatalf("accept: %v", err)
}
legs := exportedLegsByAsset(t, cChainSM, proxyChain, taker)
t.Logf("AFTER GENESIS-GATED UNATTESTED BLOCK: base proceeds=%d quote refund=%d", legs[baseAsset], legs[quoteAsset])
if legs[baseAsset] != 0 {
t.Fatalf("FABRICATED FILL SETTLED under a genesis-pinned key: %d base proceeds moved — the gate is "+
"still inert in the production path.", legs[baseAsset])
}
if legs[quoteAsset] != lockedQuote {
t.Fatalf("fail-secure refund broken: quote refund=%d, want the full locked %d.", legs[quoteAsset], lockedQuote)
}
}
// TestRED_GenesisAttestation_WrongWidthPubKeyFailsClosed proves a malformed genesis pubkey
// is a FATAL Initialize error, not a silent fallback to "no enforcement". A garbage key that
// parsed to "off" would re-open the fabricated-fills gap on a network that BELIEVED it had
// enforcement on.
func TestRED_GenesisAttestation_WrongWidthPubKeyFailsClosed(t *testing.T) {
bad := struct {
FillAttestationPubKey string `json:"fillAttestationPubKey"`
}{FillAttestationPubKey: hex.EncodeToString([]byte{1, 2, 3, 4})} // 4 bytes, not 32
blob, _ := json.Marshal(bad)
_, _, _, _, initErr := newGenesisHarness(t, nil, blob, nil)
if initErr == nil {
t.Fatalf("a wrong-width genesis fillAttestationPubKey must FAIL Initialize (fail-closed), but it succeeded")
}
t.Logf("FAIL-CLOSED OK: malformed genesis pubkey rejected at Initialize: %v", initErr)
}
// TestRED_GenesisAttestation_NotSettableViaRuntimeConfig proves the consensus-affecting
// invariant: the pubkey is GENESIS-pinned and parseConfig DELIBERATELY does not read it, so a
// per-node runtime config blob cannot set (or vary) it. If it could, two validators with
// different config would reach different trust decisions for the same block and FORK the
// StateRoot.
func TestRED_GenesisAttestation_NotSettableViaRuntimeConfig(t *testing.T) {
pub, _, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatalf("keygen: %v", err)
}
// Put the pubkey in the runtime CONFIG blob (not genesis). parseConfig must ignore it.
cfg := struct {
FillAttestationPubKey []byte `json:"fillAttestationPubKey"`
}{FillAttestationPubKey: pub}
cfgBlob, _ := json.Marshal(cfg)
cvm, _, _, _, initErr := newGenesisHarness(t, nil, nil, cfgBlob)
if initErr != nil {
t.Fatalf("Initialize with a (benign) config blob must succeed, got: %v", initErr)
}
if len(cvm.inner.Config.FillAttestationPubKey) != 0 {
t.Fatalf("runtime config set FillAttestationPubKey (%d bytes) — it is consensus-affecting and MUST be "+
"genesis-pinned ONLY; a per-node config value forks the StateRoot.",
len(cvm.inner.Config.FillAttestationPubKey))
}
t.Logf("INVARIANT OK: runtime config cannot set the genesis-pinned attestation pubkey")
}
-264
View File
@@ -1,264 +0,0 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//go:build redteam
// redteam_relay_in_verify_test.go — RED TEAM proof for the HEADLINE finding:
// "Network RPC (clob_submit) executed per-validator inside block processing —
// per-validator fills split consensus."
//
// THE FINDING (verbatim core): a VM whose block output must be a pure function of
// (height, carried timestamp, tx bytes) CANNOT fetch consensus-relevant data
// per-validator. clob_submit is STATE-MUTATING on the d-chain AND its returned
// fills depend on WHEN the call lands (the book moves between calls). If each
// validator issues its own clob_submit while processing the block, each receives
// independently-timed fills => different settlement export => different StateRoot
// => the network cannot agree. "Fills must be carried INTO the block ... and
// verified from bytes, not fetched."
//
// These tests model that EXACTLY: a single submit block is processed+accepted on
// TWO validators against a matcher that returns DIFFERENT fills per call (the
// book moved between their independent relays). The consensus requirement is that
// both validators derive the IDENTICAL StateRoot and the IDENTICAL exported value
// from the same block bytes. They FAIL against any design that relays per
// validator (whether in Verify or at Accept) and PASS only once the fills are
// carried in the block and settled purely from those carried bytes.
package dexvm
import (
"context"
"encoding/binary"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/luxfi/database/memdb"
"github.com/luxfi/database/prefixdb"
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/runtime"
"github.com/luxfi/vm"
dexatomic "github.com/luxfi/vm/chains/atomic"
"github.com/luxfi/warp"
"github.com/luxfi/chains/dexvm/config"
)
// movingBookMatcher is a fake d-chain that returns a DIFFERENT fill on each
// clob_submit call — the literal "the book moves between calls" condition. The
// FIRST validator to relay sees perCall[0]; the second sees perCall[1]; etc. It
// is the ground truth for "the matcher is consensus-relevant per-call state".
type movingBookMatcher struct {
mu sync.Mutex
calls int64
perCall [][]Fill
}
func (m *movingBookMatcher) Call(_ context.Context, method string, _ []byte) ([]byte, error) {
if method != ZAPMethodSubmit {
ack := make([]byte, 17)
binary.BigEndian.PutUint64(ack[0:8], 1)
return ack, nil
}
i := atomic.AddInt64(&m.calls, 1) - 1
m.mu.Lock()
defer m.mu.Unlock()
idx := int(i)
if idx >= len(m.perCall) {
idx = len(m.perCall) - 1
}
return encodeFillsWire(m.perCall[idx]), nil
}
func (m *movingBookMatcher) Close() error { return nil }
// newValidatorVM builds a fresh proxy VM (one "validator") wired to the supplied
// zapConn matcher and REAL two-chain shared memory, with a pinned C-Chain id so
// the settlement export destination is identical across validators. It mirrors
// the conservation harness init but injects an arbitrary matcher and a fixed
// destination chain — the two degrees of freedom this finding needs.
func newValidatorVM(t *testing.T, conn zapConn, cChainID ids.ID) (*VM, dexatomic.SharedMemory, ids.ID) {
t.Helper()
logger := log.NewNoOpLogger()
baseDB := memdb.New()
m := dexatomic.NewMemory(prefixdb.New([]byte{0}, baseDB))
proxyChain := ids.GenerateTestID()
proxySM := m.NewSharedMemory(proxyChain)
cChainSM := m.NewSharedMemory(cChainID)
prev := zapDialer
zapDialer = func(_ context.Context, _ string) (zapConn, error) { return conn, nil }
t.Cleanup(func() { zapDialer = prev })
rt := &runtime.Runtime{
ChainID: proxyChain,
CChainID: cChainID,
NetworkID: 96369,
Log: logger,
SharedMemory: proxySM,
}
v := &VM{}
v.Config = config.DefaultConfig()
v.Config.DexZapEndpoint = "127.0.0.1:0"
if err := v.Initialize(context.Background(), vm.Init{
Runtime: rt,
DB: prefixdb.New([]byte{1}, baseDB),
ToEngine: make(chan vm.Message, 8),
Sender: warp.FakeSender{},
Log: logger,
}); err != nil {
t.Fatalf("init validator vm: %v", err)
}
v.bootstrapped = true
return v, cChainSM, proxyChain
}
// exportedToTaker sums the value the proxy committed to C-Chain shared memory for
// the taker trait — the proceeds the C-Chain can claim post-accept. Each element
// value is owner(20)|asset(32)|amount(8).
func exportedToTaker(t *testing.T, cChainSM dexatomic.SharedMemory, proxyChain ids.ID, taker ids.ShortID) uint64 {
t.Helper()
vals, _, _, err := cChainSM.Indexed(proxyChain, [][]byte{taker[:]}, nil, nil, 100)
if err != nil {
t.Fatalf("indexed taker proceeds: %v", err)
}
var total uint64
for _, v := range vals {
if len(v) >= exportedOutputSize {
total += binary.BigEndian.Uint64(v[53:61])
}
}
return total
}
// TestRED_PerValidatorRelay_SplitsConsensus is the headline proof. ONE submit
// block (byte-identical on every node) is processed and accepted on TWO
// independent validators. The d-chain's book MOVED between their two independent
// relays, so validator A's relay returns 1000 base filled and validator B's
// returns 600 base filled. A consensus VM MUST derive the same StateRoot and the
// same exported proceeds from the same block bytes — otherwise the two validators
// disagree on the post-accept shared-memory commit and the chain forks.
//
// Under per-validator relay (the relay frame is sent during block processing on
// each node — Verify OR Accept), this FAILS: the two validators export different
// amounts and commit different roots. The fix carries the matcher's fills INTO
// the block at build time (one relay, by the proposer) and settles purely from
// those carried bytes, so both validators reproduce byte-identical output. The
// matcher's per-call counter then proves the d-chain was hit AT MOST ONCE for the
// whole network.
func TestRED_PerValidatorRelay_SplitsConsensus(t *testing.T) {
ctx := context.Background()
const height = uint64(1)
blockTime := time.Unix(1_700_000_000, 0)
// The book moves: first relay fills 1000 base @1; a later relay fills only 600.
matcher := &movingBookMatcher{perCall: [][]Fill{
{{Price: fp(1), Size: 1000, Side: 0}},
{{Price: fp(1), Size: 600, Side: 0}},
}}
// Fixed ids so the block is byte-identical across both validators — the ONLY
// variable is which fills each validator's own relay happens to observe.
taker := fixedShortID("per-validator-taker")
asset := idFromByte(0xa1)
srcUTXOID := idFromByte(0x1a)
sharedCChain := idFromByte(0xce)
// The byte-identical block both validators process: import locks 1000, then a
// clob_submit relay bound to that escrow.
importTx := newImportTxBytes(t, taker, sharedCChain, srcUTXOID, asset, 1000)
relayTx := newRelayTxBytes(t, taker, srcUTXOID, clobSubmitPayload(asset, 1000))
blockTxs := [][]byte{importTx, relayTx}
runValidator := func(label string) (root ids.ID, exported uint64) {
v, cChainSM, proxyChain := newValidatorVM(t, matcher, sharedCChain)
// Seed the C-Chain -> proxy export the import consumes (byte-identical id).
seedFixedUTXO(t, cChainSM, proxyChain, taker, asset, srcUTXOID, 1000)
res, err := v.ProcessBlock(ctx, height, blockTime, blockTxs)
if err != nil {
t.Fatalf("%s ProcessBlock: %v", label, err)
}
if err := v.acceptBlock(ctx, res); err != nil {
t.Fatalf("%s acceptBlock: %v", label, err)
}
return res.StateRoot, exportedToTaker(t, cChainSM, proxyChain, taker)
}
rootA, expA := runValidator("validator A")
rootB, expB := runValidator("validator B")
t.Logf("validator A: exported=%d root=%s", expA, rootA.Hex()[:16])
t.Logf("validator B: exported=%d root=%s", expB, rootB.Hex()[:16])
t.Logf("d-chain clob_submit calls across BOTH validators: %d", atomic.LoadInt64(&matcher.calls))
if expA != expB {
t.Fatalf("CONSENSUS SPLIT (value): same block bytes, but validator A exported %d and "+
"validator B exported %d — each settled its OWN per-validator clob_submit fills "+
"(the book moved between their relays). The post-accept shared-memory commit "+
"diverges; the chain forks. Fills must be carried in the block and settled from "+
"bytes, not fetched per validator.", expA, expB)
}
if rootA != rootB {
t.Fatalf("CONSENSUS SPLIT (root): same block bytes => different StateRoot (A=%s B=%s). "+
"The block output is NOT a pure function of (height, carried time, tx bytes) because "+
"each validator fetched its own fills.", rootA.Hex()[:16], rootB.Hex()[:16])
}
// Once fills are carried in the block, the matcher is hit AT MOST ONCE for the
// whole network (the proposer's build-time relay), never once per validator.
if c := atomic.LoadInt64(&matcher.calls); c > 1 {
t.Fatalf("N-FOLD SUBMISSION: the state-mutating clob_submit hit the d-chain %d times for "+
"ONE order — once per validator. The same order matched %d times against resting "+
"makers on the source-of-truth matcher. It must be relayed exactly once.", c, c)
}
}
// TestRED_VerifyDoesNotRelay pins the purity of the VERIFY path directly: running
// ProcessBlock (Verify) for a submit must perform ZERO d-chain calls. Verify runs
// on every validator and must be a pure function of bytes; any relay there is the
// finding. (Accept is also per-validator, so the fix forbids the relay there too;
// the split test above covers accept — this isolates the Verify leg.)
func TestRED_VerifyDoesNotRelay(t *testing.T) {
ctx := context.Background()
matcher := &movingBookMatcher{perCall: [][]Fill{{{Price: fp(1), Size: 1000, Side: 0}}}}
taker := fixedShortID("verify-purity-taker")
asset := idFromByte(0xb2)
srcUTXOID := idFromByte(0x2b)
cChain := idFromByte(0xcf)
v, cChainSM, proxyChain := newValidatorVM(t, matcher, cChain)
seedFixedUTXO(t, cChainSM, proxyChain, taker, asset, srcUTXOID, 1000)
importTx := newImportTxBytes(t, taker, cChain, srcUTXOID, asset, 1000)
relayTx := newRelayTxBytes(t, taker, srcUTXOID, clobSubmitPayload(asset, 1000))
if _, err := v.ProcessBlock(ctx, 1, time.Unix(1, 0), [][]byte{importTx, relayTx}); err != nil {
t.Fatalf("ProcessBlock: %v", err)
}
if c := atomic.LoadInt64(&matcher.calls); c != 0 {
t.Fatalf("VERIFY IS IMPURE: ProcessBlock (Verify) issued %d d-chain clob_submit call(s). "+
"Verify runs on every validator and must be a pure function of (height, carried time, "+
"tx bytes); it may NOT fetch consensus-relevant data. (calls=%d)", c, c)
}
}
// fixedShortID returns a deterministic 20-byte address from a label so two
// validators build byte-identical blocks (ids.GenerateTestShortID is random).
func fixedShortID(label string) ids.ShortID {
var a ids.ShortID
copy(a[:], label)
return a
}
// idFromByte returns a deterministic 32-byte id filled from one byte — a stable
// stand-in for ids.GenerateTestID where cross-validator byte-identity matters.
func idFromByte(b byte) ids.ID {
var id ids.ID
for i := range id {
id[i] = b
}
return id
}
-222
View File
@@ -1,222 +0,0 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//go:build redteam
// redteam_stateroot_test.go — RED TEAM proof that the block StateRoot is a
// FAITHFUL commitment to the proxy's mutated state, not merely a re-encoding of
// the block hash.
//
// THE FINDING: computeStateRoot used to hash ONLY blockHash[:] and the height.
// It committed NOTHING about the consumed-UTXO set, collateral escrow, relay
// receipts, or the accumulated cross-chain (import/export) requests. So two
// validators whose state GENUINELY diverged — one applied an import + settled an
// export, a peer whose relay failed (vm.go: "individual tx failures don't fail
// the block; log and continue") committed no cross-chain op — emitted the SAME
// StateRoot whenever their block hash matched. Real divergence was invisible to
// the root-based safety check.
//
// These tests process the SAME block (same height + time => same blockHash) on
// harnesses that end in DIFFERENT states, and assert the roots DIFFER while the
// block hash is IDENTICAL. They FAIL against the old blockHash-only root (roots
// collide) and PASS against the faithful root. The final test pins the converse:
// identical state still yields identical roots (determinism).
package dexvm
import (
"context"
"crypto/sha256"
"encoding/binary"
"testing"
"time"
"github.com/luxfi/ids"
"github.com/luxfi/vm/chains/atomic"
"github.com/luxfi/chains/dexvm/txs"
)
// seedFixedUTXO exports `amount` of `asset` from C-Chain to the proxy under a
// CALLER-CHOSEN utxoID, so two harnesses can be seeded with the byte-identical
// source UTXO (the shared seedExportedUTXO helper randomizes the id, which would
// itself fork the consumed-set state). Mirrors that helper otherwise.
func seedFixedUTXO(t *testing.T, cChainSM atomic.SharedMemory, proxyChain ids.ID, owner ids.ShortID, asset, utxoID ids.ID, amount uint64) {
t.Helper()
val := encodeExportedOutput(txs.AtomicOutput{Owner: owner, Asset: asset, Amount: amount})
if err := cChainSM.Apply(map[ids.ID]*atomic.Requests{
proxyChain: {PutRequests: []*atomic.Element{{
Key: utxoID[:],
Value: val,
Traits: [][]byte{owner[:]},
}}},
}); err != nil {
t.Fatalf("seed fixed UTXO: %v", err)
}
}
// legacyBlockHashOnlyRoot reproduces the OLD computeStateRoot (blockHash || height)
// — the root that committed no actual state. The tests use it to show that the
// divergent states being distinguished COLLIDE under the old scheme, so the new
// root is precisely what makes the divergence visible.
func legacyBlockHashOnlyRoot(blockHash ids.ID, height uint64) ids.ID {
h := sha256.New()
h.Write(blockHash[:])
var heightBuf [8]byte
binary.BigEndian.PutUint64(heightBuf[:], height)
h.Write(heightBuf[:])
return ids.ID(h.Sum(nil))
}
// TestRED_StateRoot_DivergentRelayOutcomeForksRoot is the HEADLINE proof that the
// faithful StateRoot commits the SETTLEMENT — now exercised against the post-#9
// residual divergence vector: divergent CARRIED FILLS.
//
// Under the carried-fills model the relay happens once at the proposer's build and
// NO node relays at accept, so the old "one validator's accept-relay fails" fork is
// structurally impossible (proven by TestRED_PerValidatorRelay_SplitsConsensus).
// The divergence that COULD still matter is two nodes settling DIFFERENT carried
// fills for the same block coordinate — e.g. an equivocating proposer that gossips
// two blocks with the same (height, time, txs) but different fills, or a tampered
// block. The faithful root MUST distinguish them: a node that settles 1000 base of
// proceeds (consuming the escrow into a base export) and a node that settles a
// 600-base partial (base export + 400 refund) produce DIFFERENT consumed/escrow/
// export state, hence different roots. The legacy blockHash-only root collides on
// both (same height+time => same blockHash), so it could not.
func TestRED_StateRoot_DivergentRelayOutcomeForksRoot(t *testing.T) {
ctx := context.Background()
const height = uint64(1)
blockTime := time.Unix(1_700_000_000, 0)
wantBlockHash := deriveBlockHash(height, blockTime)
// FIXED ids so both nodes process a BYTE-IDENTICAL block (same blockHash) — the
// ONLY difference is the CARRIED FILLS each node settles.
var taker ids.ShortID
copy(taker[:], "fixed-taker-addr-1234")
asset := ids.ID{0xa5}
srcUTXOID := ids.ID{0x5a}
// relayTxIndex is the clob_submit relay's position in the block (import is 0).
const relayTxIndex = uint32(1)
// runBlock processes the identical import+relay block and settles the SUPPLIED
// carried fills (modeling a node that received block bytes carrying those fills).
runBlock := func(carried []Fill) *BlockResult {
h := newConservationHarness(t, nil)
h.exportTaker = taker
seedFixedUTXO(t, h.cChainSM, h.proxyChain, taker, asset, srcUTXOID, 1000)
importTx := newImportTxBytes(t, taker, h.cChain, srcUTXOID, asset, 1000)
relayTx := newRelayTxBytes(t, taker, srcUTXOID, clobSubmitPayload(asset, 1000))
res, err := h.vm.ProcessBlock(ctx, height, blockTime, [][]byte{importTx, relayTx})
if err != nil {
t.Fatalf("ProcessBlock: %v", err)
}
// Settle from the CARRIED fills (no node relays at accept; the fills came
// from the block bytes). This is exactly what Block.Verify threads in.
res.carriedFills = []carriedFill{{txIndex: relayTxIndex, fills: carried}}
if err := h.vm.acceptBlock(ctx, res); err != nil {
t.Fatalf("acceptBlock: %v", err)
}
return res
}
// Node A settles a full 1000-base fill; node B settles a 600-base partial (the
// remaining 400 quote refunds). Same block bytes, DIFFERENT carried fills.
resA := runBlock([]Fill{{Price: fp(1), Size: 1000, Side: 0}})
resB := runBlock([]Fill{{Price: fp(1), Size: 600, Side: 0}})
// Block hashes are identical, so the OLD blockHash-only root — a pure function
// of (blockHash, height) — necessarily collided on these divergent settlements.
if resA.blockHash != wantBlockHash || resB.blockHash != wantBlockHash {
t.Fatalf("block hash drift: A=%x B=%x want=%x", resA.blockHash[:8], resB.blockHash[:8], wantBlockHash[:8])
}
legacyRoot := legacyBlockHashOnlyRoot(wantBlockHash, height)
t.Logf("legacy blockHash-only root (identical for both settlements): %x", legacyRoot[:8])
// THE FIX: the faithful roots MUST differ — the root commits the settlement
// (escrow consumed + the export legs), so divergent carried fills are visible.
if resA.StateRoot == resB.StateRoot {
t.Fatalf("SETTLEMENT DIVERGENCE INVISIBLE TO ROOT: node A settled a full 1000-base "+
"fill and node B a 600-base partial (different export legs + escrow), yet both "+
"emitted the SAME StateRoot %x (block hash %x matched). The root does not commit "+
"the settlement.", resA.StateRoot[:8], wantBlockHash[:8])
}
t.Logf("faithful roots fork on divergent carried fills: A=%x B=%x", resA.StateRoot[:8], resB.StateRoot[:8])
}
// TestRED_StateRoot_ConsumedUTXOSetForksRoot isolates ONE state component the old
// root ignored: the consumed-UTXO set. Two validators process the SAME (height,
// time) empty block — identical blockHash, identical (empty) atomic requests —
// but one inherited an extra consumed UTXO from a prior block. The faithful root
// must reflect that divergent state; the legacy root cannot (it never reads the
// consumed set).
func TestRED_StateRoot_ConsumedUTXOSetForksRoot(t *testing.T) {
ctx := context.Background()
const height = uint64(2)
blockTime := time.Unix(1_700_000_500, 0)
rootWith := func(extraConsumed ids.ID) ids.ID {
h := newConservationHarness(t, nil)
if extraConsumed != ids.Empty {
if err := h.vm.state.MarkConsumed(extraConsumed); err != nil {
t.Fatalf("seed consumed: %v", err)
}
}
res, err := h.vm.ProcessBlock(ctx, height, blockTime, nil)
if err != nil {
t.Fatalf("ProcessBlock: %v", err)
}
return res.StateRoot
}
rootPlain := rootWith(ids.Empty)
rootExtra := rootWith(ids.ID{0x42})
if rootPlain == rootExtra {
t.Fatalf("CONSUMED-SET DIVERGENCE INVISIBLE: a validator carrying an extra "+
"consumed UTXO produced the SAME StateRoot %x as one without it, for the same "+
"block. The root ignores the consumed-UTXO set.", rootPlain[:8])
}
legacyRoot := legacyBlockHashOnlyRoot(deriveBlockHash(height, blockTime), height)
t.Logf("legacy root (identical for both): %x", legacyRoot[:8])
t.Logf("consumed-set divergence forks faithful root: plain=%x extra=%x", rootPlain[:8], rootExtra[:8])
}
// TestRED_StateRoot_IdenticalStateMatchesRoot is the CONVERSE: identical inputs
// MUST yield identical roots (determinism). A faithful root that forked on
// matching state would be useless for consensus. The block is built from FIXED
// ids so nothing random forks the state, processed on two fresh validators.
func TestRED_StateRoot_IdenticalStateMatchesRoot(t *testing.T) {
ctx := context.Background()
const height = uint64(1)
blockTime := time.Unix(1_700_000_000, 0)
run := func() ids.ID {
h := newConservationHarness(t, nil)
var taker ids.ShortID
copy(taker[:], "fixed-taker-addr-1234")
asset := ids.ID{0xcd}
fixedUTXO := ids.ID{0xab}
// FIXED source chain too: executeImport keys the atomic RemoveRequests by
// SourceChain and the faithful root commits them, so the harness's random
// cChain would (correctly) fork the root. A determinism check must hold every
// input fixed.
srcChain := ids.ID{0xef}
importTx := newImportTxBytes(t, taker, srcChain, fixedUTXO, asset, 1000)
res, err := h.vm.ProcessBlock(ctx, height, blockTime, [][]byte{importTx})
if err != nil {
t.Fatalf("ProcessBlock: %v", err)
}
return res.StateRoot
}
if r1, r2 := run(), run(); r1 != r2 {
t.Fatalf("DETERMINISM BROKEN: identical block on two validators produced "+
"DIFFERENT roots %x vs %x — a faithful root must be a pure function of "+
"(inputs, state).", r1[:8], r2[:8])
} else {
t.Logf("identical state => identical root: %x", r1[:8])
}
}
-288
View File
@@ -1,288 +0,0 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//go:build redteam
// redteam_swap_proceeds_test.go — the HIGH-1 happy-path proof: a swap's PROCEEDS
// leg is exported under the REAL output-asset id, so the C-side ImportSettlement can
// actually credit the taker and the swap COMPLETES.
//
// THE BUG (now closed): settleFromFills exported the proceeds leg under
// assetFromRef(collateralRef, leg) = SHA256(ref||leg) — a synthetic routing handle.
// The C-side ImportSettlement requires recAsset == claim.Asset == assetID(currency_out)
// (the injective 32-byte id that ALSO keys seamReserve[assetOut]). A SHA256 handle can
// never equal an injective assetID, so the proceeds credit ALWAYS failed with
// ErrNativeSettleAsset — the taker NEVER received their swap output. Normal swaps did
// not complete (the swarm's swapsWork=NO).
//
// THE FIX: settleFromFills exports the proceeds leg under the REAL output-asset id
// carried with the settling relay (RelayOrderTx.AssetOut). These tests drive the full
// cross-asset match -> D export and assert the proceeds object decodes under EXACTLY the
// real output asset (the value the C-side equality-binds), at both the unit level
// (direct settleFromFills) and end-to-end (proposer build -> accept -> settle).
package dexvm
import (
"context"
"encoding/binary"
"testing"
"time"
"github.com/luxfi/ids"
"github.com/luxfi/chains/dexvm/txs"
)
// decodeExportLeg reads one exported D->C object (rail|owner|asset|amount) the way the
// C-side decodeAtomicObject does, so the assertions reason in the C-side's own terms.
func decodeExportLeg(v []byte) (owner ids.ShortID, asset ids.ID, amount uint64, ok bool) {
if len(v) != exportedOutputSize {
return ids.ShortEmpty, ids.Empty, 0, false
}
copy(owner[:], v[1:21])
copy(asset[:], v[21:53])
amount = binary.BigEndian.Uint64(v[53:61])
return owner, asset, amount, true
}
// legsTo returns every exported leg credited to `owner`, keyed by asset id. This is
// the exact shape the C-side ImportSettlement consumes: for the taker to be credited,
// there must be a leg whose ASSET equals the real output asset.
func legsTo(ar *atomicRequests, owner ids.ShortID) map[ids.ID]uint64 {
out := make(map[ids.ID]uint64)
for _, reqs := range ar.reqs {
for _, e := range reqs.PutRequests {
o, a, amt, ok := decodeExportLeg(e.Value)
if ok && o == owner {
out[a] += amt
}
}
}
return out
}
// TestRED_SwapProceeds_ExportedUnderRealOutputAsset is the UNIT-level HIGH-1 proof. A
// taker BUYs (locks quote, receives base): settleFromFills MUST export the base
// proceeds under the REAL base asset id (the relay's declared AssetOut), NOT a
// SHA256(ref||leg) handle. The C-side ImportSettlement equality-binds recAsset ==
// assetID(currency_out); only an export under the real id can ever satisfy it.
func TestRED_SwapProceeds_ExportedUnderRealOutputAsset(t *testing.T) {
// Taker BUY: 100 base @ price 2 => receives 100 base, spends 200 quote.
fills := []Fill{{Price: fp(2), Size: 100, Side: 0}}
cvm, _, _, _, _ := newCountingHarness(t, fills)
taker := ids.GenerateTestShortID()
collateralRef := ids.GenerateTestID()
quoteAsset := ids.GenerateTestID() // the LOCKED (input) asset
baseAsset := ids.GenerateTestID() // the REAL output asset the taker receives
const lockedQuote uint64 = 500
if err := cvm.inner.state.PutEscrow(collateralRef, taker, quoteAsset, lockedQuote); err != nil {
t.Fatalf("PutEscrow: %v", err)
}
// Settle with the REAL output asset declared (baseAsset) — the HIGH-1 fix path.
ar := newAtomicRequests()
if err := cvm.inner.settleFromFills(taker, collateralRef, fills, baseAsset, 0, false, ids.GenerateTestID(), 0, ar); err != nil {
t.Fatalf("settleFromFills: %v", err)
}
legs := legsTo(ar, taker)
// THE PROCEEDS LEG MUST BE UNDER THE REAL OUTPUT ASSET. Pre-fix this was a
// SHA256(ref||leg) handle the C-side could never match.
proceeds, ok := legs[baseAsset]
if !ok {
t.Fatalf("HIGH-1 NOT FIXED: no proceeds leg exported under the real output asset %s. "+
"Exported legs: %v. The C-side ImportSettlement binds recAsset==assetID(currency_out); "+
"a proceeds leg under any other id (e.g. a SHA256(ref||leg) handle) is permanently unclaimable.",
baseAsset, legs)
}
if proceeds != 100 {
t.Fatalf("proceeds amount = %d, want 100 base (sum(size)=100)", proceeds)
}
// The refund leg is the unspent locked quote (500 locked - 200 spent = 300), under
// the REAL quote asset. Conservation: 100 base proceeds + 300 quote refund.
if refund := legs[quoteAsset]; refund != 300 {
t.Fatalf("refund leg = %d quote, want 300 (locked 500 - spent 200)", refund)
}
// Belt-and-braces: assert NO leg is exported under the OLD synthetic handle, so a
// regression that re-introduces assetFromRef would be caught here.
staleHandle := ids.ID(idHash(append(append([]byte{}, collateralRef[:]...), 0)))
if _, bad := legs[staleHandle]; bad {
t.Fatalf("REGRESSION: proceeds exported under the stale SHA256(ref||leg) handle %s — "+
"the C-side can never claim it.", staleHandle)
}
}
// TestRED_SwapProceeds_EndToEnd_TakerCreditedRealOutput is the END-TO-END HIGH-1
// happy-path proof on the real import -> relay -> build -> accept -> settle path. A
// taker imports quote collateral, submits a SETTLING relay that declares the real base
// output asset, and after accept the taker is credited the correct base amount UNDER
// THE REAL BASE ASSET — the exact object the C-side ImportSettlement consumes to credit
// the swap output. This is the proof that swaps actually complete.
func TestRED_SwapProceeds_EndToEnd_TakerCreditedRealOutput(t *testing.T) {
// Taker BUY: book fills 150 base @ price 2 => 150 base received, 300 quote spent.
fills := []Fill{{Price: fp(2), Size: 150, Side: 0}}
cvm, matcher, cChainSM, proxyChain, _ := newCountingHarness(t, fills)
ctx := context.Background()
taker := ids.GenerateTestShortID()
quoteAsset := ids.GenerateTestID() // locked input
baseAsset := ids.GenerateTestID() // real output
const lockedQuote uint64 = 1000
// The C-Chain exported `lockedQuote` of the quote asset to the proxy for the taker
// (the import binds the escrow owner = taker from the recorded UTXO).
srcUTXOID := seedExportedUTXO(t, cChainSM, proxyChain, taker, quoteAsset, lockedQuote)
importTx := newImportTxBytes(t, taker, cvm.inner.cChainID(), srcUTXOID, quoteAsset, lockedQuote)
// The taker submits a SETTLING relay declaring the REAL output asset (baseAsset) —
// what the keeper builds once it has resolved the market's output side.
relayTx := newSettlingRelayTxBytes(t, taker, srcUTXOID, baseAsset, clobSubmitPayload(quoteAsset, lockedQuote), 0, false)
// Proposer builds (relays once, carries fills) + accepts (settle from carried).
cvm.inner.clock.Set(time.Unix(1, 0))
cvm.pendingTxs = [][]byte{importTx, relayTx}
built, err := cvm.BuildBlock(ctx)
if err != nil {
t.Fatalf("BuildBlock: %v", err)
}
if err := cvm.SetPreference(ctx, built.ID()); err != nil {
t.Fatalf("SetPreference: %v", err)
}
if err := built.Verify(ctx); err != nil {
t.Fatalf("verify: %v", err)
}
if err := built.Accept(ctx); err != nil {
t.Fatalf("accept: %v", err)
}
// Read what the proxy exported to the taker on the C-Chain, by asset. This is the
// set of objects the C-side ImportSettlement will consume.
legs := exportedLegsByAsset(t, cChainSM, proxyChain, taker)
t.Logf("AFTER CROSS-ASSET SWAP SETTLE: base proceeds=%d (under %s) quote refund=%d (under %s)",
legs[baseAsset], baseAsset, legs[quoteAsset], quoteAsset)
// THE SWAP COMPLETES: the taker has a proceeds object UNDER THE REAL BASE ASSET for
// the correct amount (150 base). The C-side ImportSettlement (recAsset==assetID(
// currency_out)) can now credit it — pre-fix this object was under a SHA256 handle
// and the credit ALWAYS reverted with ErrNativeSettleAsset.
if legs[baseAsset] != 150 {
t.Fatalf("HIGH-1 (swapsWork=NO) NOT FIXED: taker base proceeds under the real output asset = %d, want 150. "+
"All exported legs: %v. Without a proceeds leg under assetID(currency_out), the taker's swap output is "+
"permanently unclaimable on C.", legs[baseAsset], legs)
}
// Conservation: 150 base proceeds + (1000 - 300) = 700 quote refund.
if legs[quoteAsset] != 700 {
t.Fatalf("refund under the real quote asset = %d, want 700 (locked 1000 - spent 300)", legs[quoteAsset])
}
// Exactly one relay submit (the proposer relays once for the whole network).
if submits, _, _ := matcher.counts(); submits != 1 {
t.Fatalf("expected exactly one proposer relay submit, got %d", submits)
}
// The escrow was consumed exactly once (settled).
if _, _, _, found, _ := cvm.inner.state.GetEscrow(srcUTXOID); found {
t.Fatalf("a completed swap settle must consume the escrow exactly once (still present)")
}
}
// exportedLegsByAsset reads the proxy's exports to `owner` on the C-Chain and buckets
// the amounts by asset id (the C-side's view of the objects it will import).
func exportedLegsByAsset(t *testing.T, cChainSM atomicShared, proxyChain ids.ID, owner ids.ShortID) map[ids.ID]uint64 {
t.Helper()
vals, _, _, err := cChainSM.Indexed(proxyChain, [][]byte{owner[:]}, nil, nil, 100)
if err != nil {
t.Fatalf("indexed: %v", err)
}
out := make(map[ids.ID]uint64)
for _, v := range vals {
if _, a, amt, ok := decodeExportLeg(v); ok {
out[a] += amt
}
}
return out
}
// atomicShared is the minimal shared-memory read surface this test needs (Indexed),
// satisfied by the real atomic.SharedMemory the harness wires.
type atomicShared interface {
Indexed(peerChainID ids.ID, traits [][]byte, startTrait, startKey []byte, limit int) ([][]byte, []byte, []byte, error)
}
var _ = txs.RailSwap
// limitUnits packs a human quote-per-base price limit into the uint64 fixed-point
// ×PriceScale wire form the relay carries (the SAME grid as Fill.Price, the value
// the precompile's priceLimitToCLOB now produces).
func limitUnits(p float64) uint64 { return fp(p) }
// TestRED_SwapSlippageLimit_RejectsWorseThanLimit is the MEDIUM (bounded sandwich/MEV)
// proof. A taker BUY carries a worst-acceptable price limit (an UPPER bound: never pay
// more than `limit` quote per base). A fill at a WORSE price (above the limit) is
// REFUSED before any value moves — the escrow stays intact and reclaimable — while a
// fill at/within the limit settles normally. Without this, a sandwich that pushes the
// price beyond the taker's floor would fill them at the adversarial price.
func TestRED_SwapSlippageLimit_RejectsWorseThanLimit(t *testing.T) {
taker := ids.GenerateTestShortID()
quoteAsset := ids.GenerateTestID()
baseAsset := ids.GenerateTestID()
const lockedQuote uint64 = 1000
const limit = 2.0 // BUY ceiling: never pay more than 2 quote per base.
// (a) WORSE fill: price 3 > limit 2 (a sandwich pushed the price up). Refused.
worse := []Fill{{Price: fp(3), Size: 100, Side: 0}}
cvm, _, _, _, _ := newCountingHarness(t, worse)
ref := ids.GenerateTestID()
if err := cvm.inner.state.PutEscrow(ref, taker, quoteAsset, lockedQuote); err != nil {
t.Fatalf("PutEscrow: %v", err)
}
ar := newAtomicRequests()
err := cvm.inner.settleFromFills(taker, ref, worse, baseAsset, limitUnits(limit), true, ids.GenerateTestID(), 0, ar)
if err == nil {
t.Fatalf("MEV GUARD MISSING: a fill at price 3 (above the taker's limit 2) was settled — a "+
"sandwich could fill the taker beyond their slippage floor.")
}
if !ar.empty() {
t.Fatalf("a refused (out-of-limit) settle must export nothing")
}
// The escrow is intact and reclaimable (the refused settle did not consume it).
if _, _, _, found, _ := cvm.inner.state.GetEscrow(ref); !found {
t.Fatalf("a refused out-of-limit settle must leave the escrow intact for reclaim")
}
t.Logf("MEV GUARD: fill@3 rejected against BUY limit 2 (escrow intact): %v", err)
// (b) AT-LIMIT fill: price 2 == limit 2. Settles normally (100 base proceeds).
atLimit := []Fill{{Price: fp(2), Size: 100, Side: 0}}
cvm2, _, _, _, _ := newCountingHarness(t, atLimit)
ref2 := ids.GenerateTestID()
if err := cvm2.inner.state.PutEscrow(ref2, taker, quoteAsset, lockedQuote); err != nil {
t.Fatalf("PutEscrow: %v", err)
}
ar2 := newAtomicRequests()
if err := cvm2.inner.settleFromFills(taker, ref2, atLimit, baseAsset, limitUnits(limit), true, ids.GenerateTestID(), 0, ar2); err != nil {
t.Fatalf("an at-limit fill (price == limit) must settle, got: %v", err)
}
if legsTo(ar2, taker)[baseAsset] != 100 {
t.Fatalf("at-limit fill must credit 100 base proceeds, got %v", legsTo(ar2, taker))
}
t.Logf("MEV GUARD: fill@2 accepted at BUY limit 2 (100 base credited)")
// (c) SELL floor: a SELL carries a LOWER bound (never receive less than `limit` quote
// per base). A fill BELOW the floor is refused.
sellFloor := []Fill{{Price: fp(4), Size: 100, Side: 1}}
cvm3, _, _, _, _ := newCountingHarness(t, sellFloor)
ref3 := ids.GenerateTestID()
if err := cvm3.inner.state.PutEscrow(ref3, taker, baseAsset /*locked base for a sell*/, lockedQuote); err != nil {
t.Fatalf("PutEscrow: %v", err)
}
ar3 := newAtomicRequests()
if err := cvm3.inner.settleFromFills(taker, ref3, sellFloor, quoteAsset, limitUnits(5.0), false, ids.GenerateTestID(), 0, ar3); err == nil {
t.Fatalf("SELL floor MISSING: a fill at price 4 (below the floor 5) was settled.")
}
t.Logf("MEV GUARD: SELL fill@4 rejected against floor 5")
}
-319
View File
@@ -1,319 +0,0 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//go:build redteam
// redteam_vertex_clock_test.go — RED TEAM proof for the DAG-vertex wall-clock
// determinism finding: DexVertex.Verify must process with the proposer-CARRIED
// block time (serialized in the vertex bytes, committed by the vertex id), NOT
// the local wall clock (mockable.Clock.Time() == time.Now() when unfaked). The
// block time flows through deriveBlockHash into relay-receipt keys AND
// computeStateRoot, so a per-validator wall clock would fork the StateRoot for
// the SAME vertex => consensus split.
//
// The linear Block path (block.go) was always safe (it carries b.timestamp);
// these tests pin the DAG path to the same invariant and FAIL against the old
// code (which injected v.vm.inner.clock.Time() in Verify).
//
// The assertions touch only ProcessBlock (stable signature) and result.StateRoot,
// so they don't break as the settlement-root internals evolve.
package dexvm
import (
"context"
"testing"
"time"
"github.com/luxfi/ids"
)
// buildVertexAt drives the real BuildVertex with the proxy clock pinned to `at`,
// so the proposer-chosen timestamp is deterministic for the test. Returns the
// serialized vertex bytes (what consensus gossips to every validator).
func buildVertexAt(t *testing.T, cvm *ChainVM, at time.Time, txBytes [][]byte) []byte {
t.Helper()
cvm.inner.clock.Set(at)
cvm.pendingTxs = append(cvm.pendingTxs, txBytes...)
v, err := cvm.BuildVertex(context.Background())
if err != nil {
t.Fatalf("BuildVertex: %v", err)
}
return v.Bytes()
}
// referenceRoot computes the StateRoot a fresh proxy produces for (height, time,
// txs) via the stable ProcessBlock path — the deterministic ground truth a
// correct Verify must reproduce from the CARRIED time.
func referenceRoot(t *testing.T, height uint64, blockTime time.Time, txBytes [][]byte) ids.ID {
t.Helper()
ref, _, _, _, _ := newCountingHarness(t, nil)
res, err := ref.inner.ProcessBlock(context.Background(), height, blockTime, txBytes)
if err != nil {
t.Fatalf("reference ProcessBlock: %v", err)
}
return res.StateRoot
}
// TestRED_DAGVertex_WallClockSplitsStateRoot is the HEADLINE proof. One proposer
// builds a vertex (fixing the block time in its bytes). TWO validators receive
// the IDENTICAL bytes and each Verify at a DIFFERENT local wall-clock instant
// (their clocks are deliberately skewed). The processed StateRoot — which keys
// relay receipts and feeds computeStateRoot — MUST be identical on both, or the
// network splits on a block that every honest node agreed on.
//
// Old code (Verify used clock.Time()): roots differ -> FAIL.
// Fixed code (Verify uses the carried v.timestamp): roots identical -> PASS.
func TestRED_DAGVertex_WallClockSplitsStateRoot(t *testing.T) {
ctx := context.Background()
// Proposer builds the vertex at a fixed proposal time. Use a relay (clob_submit)
// tx so both validators can Verify the same bytes without per-validator UTXO
// setup; the relay plan is deterministic and keyed by (blockHash, txIndex).
proposer, _, _, _, _ := newCountingHarness(t, nil)
maker := ids.GenerateTestShortID()
pid := ids.GenerateTestID()
relayTx := newRelayTxBytes(t, maker, ids.GenerateTestID(), clobSubmitPayload(pid, 100))
proposalTime := time.Unix(1_700_000_000, 0)
vtxBytes := buildVertexAt(t, proposer, proposalTime, [][]byte{relayTx})
// Validator A verifies with its wall clock at T (skewed +5.123s).
valA, _, _, _, _ := newCountingHarness(t, nil)
valA.inner.clock.Set(time.Unix(1_700_000_005, 123_000_000))
va, err := valA.ParseVertex(ctx, vtxBytes)
if err != nil {
t.Fatalf("validator A ParseVertex: %v", err)
}
if err := va.Verify(ctx); err != nil {
t.Fatalf("validator A Verify: %v", err)
}
rootA := va.(*DexVertex).result.StateRoot
// Validator B verifies the SAME bytes with a DIFFERENT wall clock (+9.987s).
valB, _, _, _, _ := newCountingHarness(t, nil)
valB.inner.clock.Set(time.Unix(1_700_000_009, 987_000_000))
vb, err := valB.ParseVertex(ctx, vtxBytes)
if err != nil {
t.Fatalf("validator B ParseVertex: %v", err)
}
if err := vb.Verify(ctx); err != nil {
t.Fatalf("validator B Verify: %v", err)
}
rootB := vb.(*DexVertex).result.StateRoot
t.Logf("proposalTime=%d rootA=%x rootB=%x", proposalTime.UnixNano(), rootA[:8], rootB[:8])
if rootA != rootB {
t.Fatalf("CONSENSUS SPLIT: two validators processed the SAME vertex bytes "+
"and derived DIFFERENT StateRoots (A=%x B=%x) because Verify injected "+
"each validator's local wall clock instead of the carried block time. "+
"The block time keys relay receipts + computeStateRoot.", rootA[:8], rootB[:8])
}
// And the agreed root must equal what the CARRIED proposal time deterministically
// implies (height 1 since genesis is height 0) — independent of any wall clock.
if want := referenceRoot(t, 1, proposalTime, [][]byte{relayTx}); rootA != want {
t.Fatalf("StateRoot %x does not match the carried-time derivation %x", rootA[:8], want[:8])
}
}
// TestRED_DAGVertex_VerifyIgnoresWallClock pins the invariant directly: with the
// VM clock set FAR from the vertex's carried time, Verify still derives the
// StateRoot from the carried time. If Verify consulted the clock, the derived
// root would track clockTime and this assertion would fail.
func TestRED_DAGVertex_VerifyIgnoresWallClock(t *testing.T) {
ctx := context.Background()
proposer, _, _, _, _ := newCountingHarness(t, nil)
maker := ids.GenerateTestShortID()
pid := ids.GenerateTestID()
relayTx := newRelayTxBytes(t, maker, ids.GenerateTestID(), clobSubmitPayload(pid, 7))
carried := time.Unix(1_650_000_000, 0)
vtxBytes := buildVertexAt(t, proposer, carried, [][]byte{relayTx})
v, err := proposer.ParseVertex(ctx, vtxBytes)
if err != nil {
t.Fatalf("ParseVertex: %v", err)
}
if got := v.(*DexVertex).Timestamp(); !got.Equal(carried) {
t.Fatalf("parsed timestamp %v != carried %v", got, carried)
}
// Move the VM wall clock FAR from the carried time, then Verify.
clockTime := time.Unix(1_999_999_999, 0)
proposer.inner.clock.Set(clockTime)
if err := v.Verify(ctx); err != nil {
t.Fatalf("Verify: %v", err)
}
gotRoot := v.(*DexVertex).result.StateRoot
carriedRoot := referenceRoot(t, v.Height(), carried, [][]byte{relayTx})
clockRoot := referenceRoot(t, v.Height(), clockTime, [][]byte{relayTx})
if carriedRoot == clockRoot {
t.Fatalf("test precondition broken: carried- and clock-derived roots collide (%x)", carriedRoot[:8])
}
if gotRoot == clockRoot {
t.Fatalf("Verify used the WALL CLOCK: StateRoot %x matches clock-derived %x, "+
"not carried-time-derived %x", gotRoot[:8], clockRoot[:8], carriedRoot[:8])
}
if gotRoot != carriedRoot {
t.Fatalf("StateRoot %x is neither clock- nor carried-derived (carried=%x): "+
"unexpected derivation", gotRoot[:8], carriedRoot[:8])
}
}
// TestRED_DAGVertex_TimestampRoundTripAndIDBinding proves the carried time
// survives serialize->parse byte-for-byte and that the vertex ID COMMITS to it
// (a tampered timestamp yields a different id, so a peer cannot silently swap
// the block time while keeping the same vertex id).
func TestRED_DAGVertex_TimestampRoundTripAndIDBinding(t *testing.T) {
ctx := context.Background()
cvm, _, _, _, _ := newCountingHarness(t, nil)
maker := ids.GenerateTestShortID()
pid := ids.GenerateTestID()
relayTx := newRelayTxBytes(t, maker, ids.GenerateTestID(), clobSubmitPayload(pid, 1))
carried := time.Unix(1_600_000_000, 42)
vtxBytes := buildVertexAt(t, cvm, carried, [][]byte{relayTx})
parsed, err := cvm.ParseVertex(ctx, vtxBytes)
if err != nil {
t.Fatalf("ParseVertex: %v", err)
}
pv := parsed.(*DexVertex)
if !pv.Timestamp().Equal(carried) {
t.Fatalf("round-trip timestamp %v != %v", pv.Timestamp(), carried)
}
// ID binding: a vertex identical in every field EXCEPT the timestamp must
// have a different id (otherwise the time is not committed and is malleable).
tampered := &DexVertex{
height: pv.height,
epoch: pv.epoch,
timestamp: carried.Add(time.Second),
parents: pv.parents,
rawTxs: pv.rawTxs,
}
if tampered.computeID() == pv.id {
t.Fatalf("vertex id does NOT commit to the timestamp: a different block "+
"time produced the same id %x — the time is malleable", pv.id[:8])
}
}
// TestRED_DAGVertex_SubSecondCadenceAndMonotonicClamp proves the D-Chain block
// cadence is genuinely SUB-SECOND and that the proposer's monotonicity guard
// (BuildVertex/BuildBlock: newTimestamp clamped non-decreasing vs GetLastBlockTime)
// operates at NANOSECOND resolution — dexvm has NO whole-second truncation like the
// C-Chain EVM's integer-seconds targetBlockRate floor. Regression lock: if anyone
// ever floors/rounds the proposer time to whole seconds, (a) and (d) FAIL.
//
// Three proposals from ONE proxy (state carries, so the clamp sees the real prior
// block time):
// 1. clock = T (T has a non-zero .5s sub-second component)
// 2. clock = T + 1ms (advances sub-second, SAME wall second as T)
// 3. clock = T - 1ms (BACKWARD — must be clamped, never go back)
//
// Locks four properties the order-book's fairness/determinism depends on:
//
// (a) NO whole-second truncation: ts2-ts1 == exactly 1ms and ts1 keeps its .5s.
// (b) sub-second monotonic advance: ts2 strictly after ts1.
// (c) monotonicity clamp at ns resolution: backward clock yields ts3 == ts2
// (non-decreasing), never earlier than the last block.
// (d) ordering integrity: distinct sub-second times at the SAME height derive
// DISTINCT block hashes (deriveBlockHash keys relay-receipt idempotency),
// so two sub-second blocks never alias.
func TestRED_DAGVertex_SubSecondCadenceAndMonotonicClamp(t *testing.T) {
ctx := context.Background()
cvm, _, _, _, _ := newCountingHarness(t, nil)
// A wall-clock instant with a deliberately non-zero sub-second (.5s) part, so a
// whole-second truncation would be observable as a lost fractional second.
base := time.Unix(1_700_000_000, 500_000_000)
tsAt := func(at time.Time) time.Time {
maker := ids.GenerateTestShortID()
relayTx := newRelayTxBytes(t, maker, ids.GenerateTestID(), clobSubmitPayload(ids.GenerateTestID(), 1))
vtx := buildVertexAt(t, cvm, at, [][]byte{relayTx})
v, err := cvm.ParseVertex(ctx, vtx)
if err != nil {
t.Fatalf("ParseVertex: %v", err)
}
return v.(*DexVertex).Timestamp()
}
ts1 := tsAt(base) // clock = T
ts2 := tsAt(base.Add(time.Millisecond)) // clock = T + 1ms
ts3 := tsAt(base.Add(-time.Millisecond)) // clock = T - 1ms (backward)
// (a) NO whole-second truncation: the 1ms advance survives byte-exactly, and the
// sub-second component of the first block is preserved (would be 0 if truncated).
if d := ts2.Sub(ts1); d != time.Millisecond {
t.Fatalf("sub-second cadence lost: ts2-ts1 = %v, want exactly 1ms "+
"(a whole-second floor would collapse this to 0)", d)
}
if ns := ts1.Nanosecond(); ns != 500_000_000 {
t.Fatalf("sub-second component truncated: ts1.Nanosecond()=%d, want 500000000", ns)
}
// (b) sub-second monotonic advance.
if !ts2.After(ts1) {
t.Fatalf("block time did not advance sub-second: ts1=%v ts2=%v", ts1, ts2)
}
// (c) monotonicity clamp at ns resolution: the backward clock is pinned to the
// last block time, never earlier. Non-decreasing block time is the DEX fairness
// invariant under clock skew / re-proposal.
if ts3.Before(ts2) {
t.Fatalf("MONOTONICITY VIOLATION: backward clock produced ts3=%v < ts2=%v", ts3, ts2)
}
if !ts3.Equal(ts2) {
t.Fatalf("clamp expected ts3==ts2 (%v), got %v", ts2, ts3)
}
// (d) ordering integrity: two DISTINCT sub-second times at the SAME height derive
// DISTINCT block hashes (deriveBlockHash binds the block + keys relay-receipt
// idempotency), so sub-second blocks never alias.
if deriveBlockHash(1, ts1) == deriveBlockHash(1, ts2) {
t.Fatalf("sub-second block-hash collision: two 1ms-apart blocks share a hash — " +
"relay idempotency keys would alias")
}
}
// TestRED_LinearBlock_SubSecondCadenceAndMonotonicClamp pins the SAME sub-second +
// non-decreasing guard on the linear chain.ChainVM.BuildBlock proposer path
// (chainvm.go), which carries the identical clamp as the DAG BuildVertex path. Two
// proposals: forward by 1ms, then a backward clock. ts advances by exactly 1ms
// (no whole-second floor) and the backward clock is clamped to the prior block time.
func TestRED_LinearBlock_SubSecondCadenceAndMonotonicClamp(t *testing.T) {
ctx := context.Background()
cvm, _, _, _, _ := newCountingHarness(t, nil)
buildAt := func(at time.Time) time.Time {
cvm.inner.clock.Set(at)
relayTx := newRelayTxBytes(t, ids.GenerateTestShortID(), ids.GenerateTestID(), clobSubmitPayload(ids.GenerateTestID(), 1))
cvm.pendingTxs = append(cvm.pendingTxs, relayTx)
blk, err := cvm.BuildBlock(ctx)
if err != nil {
t.Fatalf("BuildBlock: %v", err)
}
return blk.(*Block).Timestamp()
}
base := time.Unix(1_700_000_000, 250_000_000) // .25s sub-second component
b1 := buildAt(base)
b2 := buildAt(base.Add(time.Millisecond)) // +1ms, same wall second
b3 := buildAt(base.Add(-time.Millisecond)) // backward clock
if d := b2.Sub(b1); d != time.Millisecond {
t.Fatalf("linear path lost sub-second cadence: b2-b1 = %v, want 1ms", d)
}
if ns := b1.Nanosecond(); ns != 250_000_000 {
t.Fatalf("linear path truncated sub-second component: b1.Nanosecond()=%d, want 250000000", ns)
}
if b3.Before(b2) {
t.Fatalf("linear MONOTONICITY VIOLATION: backward clock produced b3=%v < b2=%v", b3, b2)
}
if !b3.Equal(b2) {
t.Fatalf("linear clamp expected b3==b2 (%v), got %v", b2, b3)
}
}
-247
View File
@@ -1,247 +0,0 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dexvm
import (
"context"
"encoding/binary"
"errors"
"fmt"
"sync"
"time"
"github.com/luxfi/rpc"
)
// ZAP CLOB method names. These are FROZEN and MUST stay byte-identical with the
// d-chain gateway (github.com/luxfi/dex/pkg/api/zap_server.go) and the EVM
// precompile (github.com/luxfi/precompile/dex/engine_zap.go). The proxy never
// interprets order semantics — it forwards these frames verbatim to the single
// source-of-truth matcher.
const (
ZAPMethodEnsureMarket = "clob_ensure_market"
ZAPMethodPlace = "clob_place"
ZAPMethodCancel = "clob_cancel"
ZAPMethodSubmit = "clob_submit"
// Custody methods — the funds-in / funds-out rail between the atomic
// shared-memory leg and the D-Chain balance ledger (where the money lives in
// the order book). FROZEN, byte-identical with the d-chain gateway
// (github.com/luxfi/dex/pkg/zapwire Method{Deposit,Withdraw,OpenMarket}).
// - clob_deposit : credit an account's available D-Chain balance from value
// this proxy atomically IMPORTED (so the book can draw from it).
// - clob_withdraw: debit an account's realized D-Chain balance; the returned
// realized amount is what this proxy atomically EXPORTS back out.
// - clob_open_market: bind a market's (base,quote) asset handles.
ZAPMethodDeposit = "clob_deposit"
ZAPMethodWithdraw = "clob_withdraw"
ZAPMethodOpenMarket = "clob_open_market"
)
// Custody wire sizes (FROZEN, == github.com/luxfi/dex/pkg/zapwire). The proxy
// re-defines them since it cannot import the cgo-tagged d-chain package; the
// hardcoded-canonical values move in lockstep with zapwire (same three-homes
// pattern the order frames use).
//
// ASSET IDENTITY IS THE FULL 32-BYTE INJECTIVE ID (AssetIDSize), NOT a truncated
// handle. Keying a value-bearing custody ledger by a truncated id is unsound: a
// truncation maps distinct assets to the same key, so two cross-chain assets whose
// ids share a leading 8-byte prefix would collide on the D-Chain balance ledger
// (and a worthless asset folding to the native-LUX key 0 could mint a native claim
// that drains the native vault). The id is therefore carried at FULL width and the
// D-Chain keys balance:/locked: by this same 32-byte id, so the proxy's atomic
// import (a cross-chain ids.ID, native == ids.Empty == all-zero) names the SAME
// ledger key the EVM precompile rail and the d-chain gateway use.
//
// Deposit/Withdraw carry the 32-byte idempotency ref (RefSize) at the tail —
// user[16]+asset[32]+amount[8]+ref[32] = 88. The ref is the originating-tx identity
// the D-Chain folds into its content-addressed seen: dedup key (the vault-drain
// fix). On THIS atomic rail the ref is the consumed import's tx id (deposit) or the
// settlement fillRef (withdraw) — each cross-chain custody op's unique reference,
// so two genuinely distinct ops are distinct on the D-Chain seen: index. The four
// FROZEN order frames (ensure/place/cancel/submit) are byte-unchanged.
const (
// AssetIDSize: FULL injective asset-id width (NOT a truncated handle). Keys the
// d-chain balance:/locked: ledger; native cross-chain asset == ids.Empty.
AssetIDSize = 32
// RefSize: idempotency reference width (originating-tx id).
RefSize = 32
// DepositReqSize / WithdrawReqSize: user[16] + asset[32] + amount[8] + ref[32].
DepositReqSize = 16 + AssetIDSize + 8 + RefSize // 88
WithdrawReqSize = 16 + AssetIDSize + 8 + RefSize // 88
// custodyAmountOff: amount[8] offset within a deposit/withdraw body
// (after user[16]+asset[32]).
custodyAmountOff = 16 + AssetIDSize // 48
// custodyRefOff: ref[32] tail offset within a deposit/withdraw body
// (after user[16]+asset[32]+amount[8]).
custodyRefOff = 16 + AssetIDSize + 8 // 56
// BalanceRespSize: status[1] + realizedAmount[8].
BalanceRespSize = 1 + 8
)
// FillWireSize is one fill in a clob_submit response: price[8]+size[8]+side[1].
// FROZEN — must equal dex/pkg/zapwire.FillWireSize and the precompile's fillWireSize.
// price is a uint64 FIXED-POINT ×PriceScale value; size is uint64 ATOMIC BASE UNITS.
const FillWireSize = 17
// PriceScale is the fixed-point scale of the wire price field (price × 1e8) — the
// matcher's PriceInt grid. FROZEN, == dex/pkg/zapwire.PriceScale. quote owed =
// size × price / PriceScale, computed in EXACT integers so no value-bearing
// float→int conversion (whose out-of-range result is architecture-dependent) can
// fork two honest validators on different CPUs.
const PriceScale = 100000000
// ErrRelayNotConfigured is returned when an order relay is attempted but no
// d-chain ZAP endpoint is configured. The proxy is inert on the relay leg until
// the venue operator points dex-zap-endpoint at a d-chain gateway.
var ErrRelayNotConfigured = errors.New("dexvm: d-chain ZAP endpoint not configured")
// zapConn is the connection contract the relay uses. Production uses
// *rpc.ZAPConn; the interface lets the VM test harness drive the relay without
// a live socket (and keeps the proxy a clean leaf over luxfi/rpc).
type zapConn interface {
Call(ctx context.Context, method string, payload []byte) ([]byte, error)
Close() error
}
// zapDialer establishes a zapConn to addr. Overridable in tests; defaults to
// the canonical rpc.ZAPDial (pinned luxfi/rpc v1.1.0).
var zapDialer = func(ctx context.Context, addr string) (zapConn, error) {
return rpc.ZAPDial(ctx, addr)
}
// RelayClient forwards byte-identical clob_* frames to the d-chain's ZAP
// gateway. It is PURE TRANSPORT to the single source-of-truth matcher: it holds
// NO order/pool/book state, performs NO matching, and never mints. This is the
// ORDER RELAY leg (NOT atomic, NOT consensus) of the two-primitive proxy; value
// settlement is the separate atomic SharedMemory import/export leg (atomic.go).
type RelayClient struct {
addr string
timeout time.Duration
mu sync.Mutex
conn zapConn
}
// NewRelayClient creates a relay targeting the d-chain ZAP endpoint (e.g.
// "127.0.0.1:9100"). An empty addr yields a relay that returns
// ErrRelayNotConfigured on every call — the inert relay leg.
func NewRelayClient(addr string, timeout time.Duration) *RelayClient {
if timeout <= 0 {
timeout = 5 * time.Second
}
return &RelayClient{addr: addr, timeout: timeout}
}
// Configured reports whether a d-chain endpoint is set.
func (r *RelayClient) Configured() bool { return r != nil && r.addr != "" }
func (r *RelayClient) dial(ctx context.Context) (zapConn, error) {
r.mu.Lock()
defer r.mu.Unlock()
if r.conn != nil {
return r.conn, nil
}
c, err := zapDialer(ctx, r.addr)
if err != nil {
return nil, fmt.Errorf("dexvm relay dial %s: %w", r.addr, err)
}
r.conn = c
return c, nil
}
// Relay forwards an opaque clob_* frame to the d-chain and returns the raw
// response bytes verbatim. The proxy does not interpret the payload beyond
// routing it to the configured matcher.
func (r *RelayClient) Relay(ctx context.Context, method string, payload []byte) ([]byte, error) {
if !r.Configured() {
return nil, ErrRelayNotConfigured
}
cctx, cancel := context.WithTimeout(ctx, r.timeout)
defer cancel()
c, err := r.dial(cctx)
if err != nil {
return nil, err
}
resp, err := c.Call(cctx, method, payload)
if err != nil {
// A failed call invalidates the cached connection so the next relay
// redials rather than reusing a broken socket.
r.mu.Lock()
if r.conn == c {
_ = r.conn.Close()
r.conn = nil
}
r.mu.Unlock()
return nil, fmt.Errorf("dexvm relay %s: %w", method, err)
}
return resp, nil
}
// Close closes the cached connection, if any.
func (r *RelayClient) Close() error {
if r == nil {
return nil
}
r.mu.Lock()
defer r.mu.Unlock()
if r.conn != nil {
err := r.conn.Close()
r.conn = nil
return err
}
return nil
}
// Fill is one execution returned by the d-chain matcher: price + size + the
// taker side this submit took with. Price is a uint64 FIXED-POINT ×PriceScale
// value (the matcher's PriceInt grid); Size is uint64 ATOMIC BASE UNITS. The
// proxy derives the settlement delta ONLY from these server-returned EXACT
// integers — never from a client-supplied amount, and never through a float
// (whose out-of-range int conversion is architecture-dependent and would fork).
type Fill struct {
Price uint64
Size uint64
Side uint8
}
// DecodeFills parses a clob_submit response: count[4] then count×(price[8] +
// size[8] + side[1]), every multi-byte field a big-endian uint64. Each field is
// range-checked so a backend that lies (or a MITM on the socket) cannot inject a
// structurally invalid fill into settlement: price/size must be strictly positive
// (a zero-price or zero-size execution is not a real fill), and side MUST be a
// valid CLOB side (0=BUY, 1=SELL). A side byte outside {0,1} is malformed wire
// exactly like a zero price — rejecting it here keeps a Fill value always
// well-formed, so no downstream consumer ever sees an impossible side. (Cross-fill
// side CONSISTENCY — "a single submit takes exactly one side" — is a
// settlement-policy invariant enforced in settleFromFills, not a per-fill wire
// property; the two checks own different invariants and do not overlap.)
func DecodeFills(data []byte) ([]Fill, error) {
if len(data) < 4 {
return nil, fmt.Errorf("fills response too short: %d", len(data))
}
n := int(binary.BigEndian.Uint32(data[0:4]))
if 4+n*FillWireSize > len(data) {
return nil, fmt.Errorf("fills response truncated: count=%d len=%d", n, len(data))
}
fills := make([]Fill, 0, n)
off := 4
for i := 0; i < n; i++ {
p := binary.BigEndian.Uint64(data[off : off+8])
s := binary.BigEndian.Uint64(data[off+8 : off+16])
side := data[off+16]
off += FillWireSize
if p == 0 {
return nil, fmt.Errorf("fill %d: invalid price %d", i, p)
}
if s == 0 {
return nil, fmt.Errorf("fill %d: invalid size %d", i, s)
}
if side > 1 {
return nil, fmt.Errorf("fill %d: invalid side %d (must be 0=BUY or 1=SELL)", i, side)
}
fills = append(fills, Fill{Price: p, Size: s, Side: side})
}
return fills, nil
}
-429
View File
@@ -1,429 +0,0 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package state manages persistent state for the DEX VM proxy.
//
// PROXY STATELESSNESS INVARIANT: this holds ZERO canonical DEX state. There are
// NO order / pool / position / tick / feeGrowth keys — matching + DEX state
// live ONLY on the d-chain. The proxy persists exactly four things, all proper
// to an atomic transport layer:
//
// 1. NONCES — per-account replay protection for proxy txs.
// 2. RELAY RECEIPTS — in-flight clob_* relays bound to (blockHash, txIndex),
// so a re-execution / reorg / retry maps to exactly one
// d-chain match (replay-idempotency).
// 3. CONSUMED UTXOs — the atomic-UTXO consumption set: source-chain UTXO ids
// already claimed by an Import, so the same exported
// value can never be imported twice.
// 4. COLLATERAL ESCROW — the locked-collateral ledger: per collateral ref, the
// (asset, amount) an Import locked into the proxy. It is
// the value-conservation witness: a settle credits the
// realized proceeds and REFUNDS the unfilled remainder of
// this locked amount, so value_in == value_out exactly.
// This is NOT canonical DEX state — it is the transport
// layer's record of value in flight, the exact analogue
// of the consumed-UTXO set for the return leg.
package state
import (
"bytes"
"crypto/sha256"
"encoding/binary"
"errors"
"fmt"
"sync"
"github.com/luxfi/database"
"github.com/luxfi/ids"
)
var (
ErrStateCorrupted = errors.New("state corrupted")
ErrUTXOAlreadySpent = errors.New("atomic UTXO already consumed")
// ErrEscrowConsumed is returned when a collateral escrow is settled twice.
ErrEscrowConsumed = errors.New("collateral escrow already settled")
// Database prefixes — replay nonces, relay receipts, consumed UTXOs,
// collateral escrow.
prefixNonce = []byte("nonce:")
prefixReceipt = []byte("receipt:")
prefixConsumed = []byte("consumed:")
prefixEscrow = []byte("escrow:")
prefixLastBlock = []byte("lastBlock")
)
// Receipt records an in-flight ZAP relay: the d-chain operation it triggered,
// keyed by the consensus binding (blockHash, txIndex) so the same logical order
// (submit, place, or cancel) maps to exactly one relay across re-execution /
// reorg / retry.
type Receipt struct {
BlockHash ids.ID `json:"blockHash"`
TxIndex uint32 `json:"txIndex"`
// RespHash is the SHA-256 of the d-chain's response wire bytes (clob_submit
// fills, or a clob_place / clob_cancel ack) — the idempotency witness. A
// retry that finds this receipt is a no-op; it never re-relays.
RespHash ids.ID `json:"respHash"`
}
// State manages the proxy's persistent state.
type State struct {
mu sync.RWMutex
db database.Database
// receiptDB is the DURABLE base layer for relay receipts ONLY — the
// underlying disk DB, NOT the versiondb in-memory layer that `db` wraps. A
// receipt is a WRITE-AHEAD INTENT that gates an irreversible external side
// effect (the d-chain relay), so it must be durable the instant it is written
// — BEFORE the relay fires and INDEPENDENT of the consensus commit/abort
// timing. Every other key (nonce/escrow/consumed/lastBlock) is ordinary block
// state and commits with the block at accept via `db`; db.Abort discards that
// in-memory layer on a crash-before-accept, which is correct for block state
// but would be fatal for the relay witness (it would re-fire the relay on
// re-Verify). Routing receipts to receiptDB decomplects the witness's
// durability from the block-commit lifecycle.
receiptDB database.Database
lastBlockID ids.ID
lastBlockHeight uint64
}
// New creates a new state manager. db is the per-block state layer (a versiondb
// committed atomically at accept); receiptDB is the DURABLE base DB the relay
// write-ahead receipts are written through to, so an idempotency witness
// survives a crash between Verify and Accept (db.Abort discards db's in-memory
// layer; receiptDB is untouched). Pass the same base DB the versiondb wraps.
func New(db, receiptDB database.Database) *State {
return &State{db: db, receiptDB: receiptDB}
}
// Initialize loads the last-accepted block pointer from the database.
func (s *State) Initialize() error {
s.mu.Lock()
defer s.mu.Unlock()
lastBlockBytes, err := s.db.Get(prefixLastBlock)
if err != nil && !errors.Is(err, database.ErrNotFound) {
return fmt.Errorf("failed to load last block: %w", err)
}
if len(lastBlockBytes) >= 40 {
copy(s.lastBlockID[:], lastBlockBytes[:32])
s.lastBlockHeight = binary.BigEndian.Uint64(lastBlockBytes[32:40])
}
return nil
}
// ---------------------------------------------------------------------------
// Nonces — replay protection for proxy txs.
// ---------------------------------------------------------------------------
func nonceKey(addr ids.ShortID) []byte {
return append(append([]byte{}, prefixNonce...), addr[:]...)
}
// GetNonce returns the current nonce for an account (0 if unseen).
func (s *State) GetNonce(addr ids.ShortID) (uint64, error) {
s.mu.RLock()
defer s.mu.RUnlock()
data, err := s.db.Get(nonceKey(addr))
if err != nil {
if errors.Is(err, database.ErrNotFound) {
return 0, nil
}
return 0, err
}
if len(data) < 8 {
return 0, ErrStateCorrupted
}
return binary.BigEndian.Uint64(data), nil
}
// SetNonce persists the nonce for an account.
func (s *State) SetNonce(addr ids.ShortID, nonce uint64) error {
s.mu.Lock()
defer s.mu.Unlock()
var buf [8]byte
binary.BigEndian.PutUint64(buf[:], nonce)
return s.db.Put(nonceKey(addr), buf[:])
}
// ---------------------------------------------------------------------------
// Atomic-UTXO consumption set — each exported UTXO claimable exactly once.
// ---------------------------------------------------------------------------
func consumedKey(utxoID ids.ID) []byte {
return append(append([]byte{}, prefixConsumed...), utxoID[:]...)
}
// IsConsumed reports whether an exported UTXO was already claimed by an Import.
func (s *State) IsConsumed(utxoID ids.ID) (bool, error) {
s.mu.RLock()
defer s.mu.RUnlock()
_, err := s.db.Get(consumedKey(utxoID))
if err == nil {
return true, nil
}
if errors.Is(err, database.ErrNotFound) {
return false, nil
}
return false, err
}
// MarkConsumed records that an exported UTXO has been claimed. It refuses a
// double-spend: a UTXO already in the set returns ErrUTXOAlreadySpent.
func (s *State) MarkConsumed(utxoID ids.ID) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, err := s.db.Get(consumedKey(utxoID)); err == nil {
return ErrUTXOAlreadySpent
} else if !errors.Is(err, database.ErrNotFound) {
return err
}
return s.db.Put(consumedKey(utxoID), []byte{1})
}
// ---------------------------------------------------------------------------
// Collateral escrow — locked-value ledger for the conservation return leg.
// ---------------------------------------------------------------------------
func escrowKey(ref ids.ID) []byte {
return append(append([]byte{}, prefixEscrow...), ref[:]...)
}
// escrowValueSize is the fixed collateral-escrow value width: owner(20) |
// asset(32) | amount(8). The OWNER leads the record — it is the AUTHORITATIVE
// settle-authority + payout target, the recorded owner of the consumed C->D UTXO
// (executeImport reads it back from shared memory and binds the credited outputs to
// it). Persisting it here is the CRITICAL escrow-theft fix: the settle leg derives
// who may settle and where the proceeds/refund go from THIS recorded owner, never
// from the unauthenticated relay tx sender, so an attacker naming a victim's
// collateral ref can neither settle it nor redirect its value.
//
// The escrow records ONLY the LOCKED (refundable) asset — assetIn. The OUTPUT asset
// the proceeds leg is exported under (the HIGH-1 fix) is NOT stored here: it is not
// known at import time (the import precedes the matching relay), and it is the OPPOSITE
// side of the market this order trades on. It is carried with the settling relay and
// passed to settleFromFills, where the proceeds leg is exported under the REAL
// assetID(currency_out) the C-side ImportSettlement requires.
const escrowValueSize = 20 + 32 + 8
// PutEscrow records the (owner, asset, amount) an Import locked under a collateral
// ref. The stored value is owner(20)||asset(32)||amount(8). owner is the recorded
// owner of the consumed C->D object (the authenticated cross-chain value's owner,
// bound in executeImport) — the only account that may later settle this escrow and
// the sole payout target for its proceeds + refund. Recording is the import leg of
// the conservation equation; the matching ConsumeEscrow at settle pays the refund.
func (s *State) PutEscrow(ref ids.ID, owner ids.ShortID, asset ids.ID, amount uint64) error {
s.mu.Lock()
defer s.mu.Unlock()
val := make([]byte, escrowValueSize)
copy(val[0:20], owner[:])
copy(val[20:52], asset[:])
binary.BigEndian.PutUint64(val[52:60], amount)
return s.db.Put(escrowKey(ref), val)
}
// GetEscrow returns the (owner, asset, amount) locked under a collateral ref. found
// is false when no escrow exists (e.g. a relay that was not preceded by an import
// in this proxy — then there is nothing to refund and nothing to settle). owner is
// the recorded owner the settle leg binds settle-authority and the payout target to.
func (s *State) GetEscrow(ref ids.ID) (owner ids.ShortID, asset ids.ID, amount uint64, found bool, err error) {
s.mu.RLock()
defer s.mu.RUnlock()
data, gerr := s.db.Get(escrowKey(ref))
if gerr != nil {
if errors.Is(gerr, database.ErrNotFound) {
return ids.ShortEmpty, ids.Empty, 0, false, nil
}
return ids.ShortEmpty, ids.Empty, 0, false, gerr
}
if len(data) < escrowValueSize {
return ids.ShortEmpty, ids.Empty, 0, false, ErrStateCorrupted
}
copy(owner[:], data[0:20])
copy(asset[:], data[20:52])
amount = binary.BigEndian.Uint64(data[52:60])
return owner, asset, amount, true, nil
}
// ConsumeEscrow deletes a collateral escrow once it has been settled, so the
// same locked collateral can never be refunded twice. It refuses to consume an
// absent escrow (ErrEscrowConsumed) — the settle path's single-claim guard,
// mirroring MarkConsumed on the import leg.
func (s *State) ConsumeEscrow(ref ids.ID) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, err := s.db.Get(escrowKey(ref)); err != nil {
if errors.Is(err, database.ErrNotFound) {
return ErrEscrowConsumed
}
return err
}
return s.db.Delete(escrowKey(ref))
}
// ---------------------------------------------------------------------------
// Relay receipts — replay-idempotency for in-flight d-chain matches.
//
// Receipts are the proxy's WRITE-AHEAD INTENT LOG over the d-chain relay and so
// live in the DURABLE receiptDB, never the versiondb `db` (which is aborted on a
// crash-before-accept). The lifecycle is two-phase, both phases durable:
//
// 1. RecordRelayIntent(blockHash, txIndex) — written BEFORE the relay fires.
// Its mere presence is the double-submit guard: a crash/restart between
// Verify and Accept leaves this witness on disk, so re-Verify sees the
// receipt and skips the relay. RespHash is zero at this point.
// 2. PutReceipt(r) — written AFTER the relay returns, finalizing the witness
// with the response hash (provenance for the optional fraud-proof channel).
//
// Phase 1 alone fully closes the double-spend window; phase 2 is provenance.
// ---------------------------------------------------------------------------
func receiptKey(blockHash ids.ID, txIndex uint32) []byte {
k := append(append([]byte{}, prefixReceipt...), blockHash[:]...)
var idx [4]byte
binary.BigEndian.PutUint32(idx[:], txIndex)
return append(k, idx[:]...)
}
// GetReceipt returns the relay receipt bound to (blockHash, txIndex), if any. It
// reads the DURABLE receiptDB so a write-ahead intent recorded before a crash is
// seen on the post-restart re-Verify — the heart of the double-submit guard.
func (s *State) GetReceipt(blockHash ids.ID, txIndex uint32) (*Receipt, bool, error) {
s.mu.RLock()
defer s.mu.RUnlock()
data, err := s.receiptDB.Get(receiptKey(blockHash, txIndex))
if err != nil {
if errors.Is(err, database.ErrNotFound) {
return nil, false, nil
}
return nil, false, err
}
if len(data) < 64 {
return nil, false, ErrStateCorrupted
}
r := &Receipt{BlockHash: blockHash, TxIndex: txIndex}
copy(r.RespHash[:], data[32:64])
return r, true, nil
}
// RecordRelayIntent durably records the write-ahead intent to relay
// (blockHash, txIndex) — phase 1 — BEFORE the relay fires. After this returns,
// a crash that aborts the versiondb cannot erase the witness, so re-Verify finds
// it (GetReceipt) and refuses to re-submit. RespHash is left zero until the
// relay returns and PutReceipt finalizes it. Writing through receiptDB.Put makes
// the witness durable immediately (not deferred to the block commit).
func (s *State) RecordRelayIntent(blockHash ids.ID, txIndex uint32) error {
s.mu.Lock()
defer s.mu.Unlock()
val := make([]byte, 64)
copy(val[0:32], blockHash[:])
// val[32:64] (RespHash) stays zero: the intent is recorded; the response is
// not known yet. Presence — not content — is the idempotency guard.
return s.receiptDB.Put(receiptKey(blockHash, txIndex), val)
}
// PutReceipt finalizes a relay receipt — phase 2 — after the relay returns,
// stamping the response hash onto the already-durable intent. The stored value
// is blockHash||respHash (the txIndex lives in the key); blockHash is
// redundant-but-cheap provenance. Written to the DURABLE receiptDB so it shares
// one home with the intent it upgrades.
func (s *State) PutReceipt(r *Receipt) error {
s.mu.Lock()
defer s.mu.Unlock()
val := make([]byte, 64)
copy(val[0:32], r.BlockHash[:])
copy(val[32:64], r.RespHash[:])
return s.receiptDB.Put(receiptKey(r.BlockHash, r.TxIndex), val)
}
// ---------------------------------------------------------------------------
// Last-accepted block pointer.
// ---------------------------------------------------------------------------
// SetLastBlock sets the last accepted block.
func (s *State) SetLastBlock(blockID ids.ID, height uint64) error {
s.mu.Lock()
defer s.mu.Unlock()
data := make([]byte, 40)
copy(data[:32], blockID[:])
binary.BigEndian.PutUint64(data[32:], height)
if err := s.db.Put(prefixLastBlock, data); err != nil {
return err
}
s.lastBlockID = blockID
s.lastBlockHeight = height
return nil
}
// GetLastBlock returns the last accepted block ID and height.
func (s *State) GetLastBlock() (ids.ID, uint64) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.lastBlockID, s.lastBlockHeight
}
// ---------------------------------------------------------------------------
// State commitment — a faithful hash of EVERY persisted key/value.
// ---------------------------------------------------------------------------
// StateHash returns a deterministic SHA-256 commitment over the proxy's CONSENSUS
// state: every consumed-UTXO marker, collateral escrow, replay nonce, and the
// last-block pointer. It is the value the block's StateRoot binds, so two nodes
// whose consensus state actually diverges (a different consumed set, a settled-
// vs-unsettled escrow) ALWAYS produce different roots — divergence can never hide
// behind a matching block hash.
//
// RELAY RECEIPTS ARE DELIBERATELY EXCLUDED (RED finding #9). Under the carried-
// fills model the d-chain relay is performed exactly ONCE, by the block PROPOSER,
// at build (VM.obtainFills); the proposer writes a relay receipt as proposer-LOCAL
// idempotency bookkeeping, but a VALIDATOR that merely parses the block bytes never
// relays and so never writes that receipt. Folding receipts into the StateRoot
// would therefore make the proposer's root diverge from every validator's for the
// IDENTICAL block — reintroducing a fork. The receipt is liveness bookkeeping, not
// consensus state, and must not be committed here. (It remains durable in
// receiptDB and is still GetReceipt-able to gate a proposer rebuild.)
//
// The walk reads the versiondb `db` (which merges this block's staged in-memory
// writes over the durable base and drops deleted keys, e.g. a consumed escrow is
// absent), skipping the receipt prefix. Keys come out in lexicographic order, so
// the digest is order-independent of write history and identical across nodes.
// Every entry is folded length-prefixed (len(key)||key||len(value)||value) so no
// two distinct keyspaces collide by concatenation. The state is tiny by the
// proxy-statelessness invariant, so the walk per block is cheap.
func (s *State) StateHash() (ids.ID, error) {
s.mu.RLock()
defer s.mu.RUnlock()
h := sha256.New()
var lenBuf [8]byte
fold := func(key, val []byte) {
binary.BigEndian.PutUint64(lenBuf[:], uint64(len(key)))
h.Write(lenBuf[:])
h.Write(key)
binary.BigEndian.PutUint64(lenBuf[:], uint64(len(val)))
h.Write(lenBuf[:])
h.Write(val)
}
// Consensus state from the versiondb, EXCLUDING relay receipts (proposer-local
// bookkeeping; see the doc above — folding them forks proposer vs validator).
dbIt := s.db.NewIterator()
defer dbIt.Release()
for dbIt.Next() {
key := dbIt.Key()
if bytes.HasPrefix(key, prefixReceipt) {
continue
}
fold(key, dbIt.Value())
}
if err := dbIt.Error(); err != nil {
return ids.Empty, fmt.Errorf("state hash: iterate db: %w", err)
}
return ids.ID(h.Sum(nil)), nil
}
// Close is a no-op flush hook (the proxy writes through to the DB directly).
func (s *State) Close() error { return nil }
-656
View File
@@ -1,656 +0,0 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package txs defines transaction types for the DEX VM — a STATELESS ATOMIC
// ZAP PROXY. There is NO local matching, NO AMM, NO embedded order book. The
// transaction surface is exactly the two concerns a proxy owns:
//
// 1. ATOMIC VALUE MOVEMENT (C-Chain <-> proxy), modeled on the platformvm
// Import/Export txs that move value between primary-network chains in a
// single atomic shared-memory commit:
// - TxImport : claim value exported from C-Chain into the proxy.
// - TxExport : settle proceeds (and unfilled IOC remainder) back to
// C-Chain, derived ONLY from confirmed d-chain fills.
//
// 2. ORDER RELAY (proxy -> d-chain over ZAP), transport only:
// - TxRelayOrder : an opaque, byte-identical clob_* ZAP payload plus a
// reference to the collateral already locked by an
// Import. The matcher is the d-chain; the proxy never
// matches.
// - TxPlaceOrder / TxCancelOrder : thin relay envelopes (a CLOB place /
// cancel forwarded verbatim). They carry NO price/size
// matching semantics in the proxy — the d-chain is the
// single source of truth.
package txs
import (
"crypto/sha256"
"errors"
"time"
"github.com/luxfi/crypto/secp256k1"
"github.com/luxfi/ids"
)
var (
// ErrInvalidSignature is returned by RelayOrderTx.Verify when a relay carries a
// signature that does not recover to its From (a spoofed-From relay). It is the
// admission-time provenance gate; settle authority itself derives from the
// consumed C->D object's recorded owner (the escrow owner), not from From.
ErrInvalidSignature = errors.New("invalid signature")
ErrInvalidTxType = errors.New("invalid transaction type")
ErrInvalidAmount = errors.New("invalid amount")
ErrInvalidPrice = errors.New("invalid price")
ErrInsufficientFunds = errors.New("insufficient funds")
)
// TxType represents the type of transaction.
type TxType uint8
const (
// TxImport claims value exported from C-Chain into the proxy (atomic
// shared-memory import leg). Consumes imported UTXOs, credits local outputs.
TxImport TxType = iota
// TxExport settles proceeds back to C-Chain (atomic shared-memory export
// leg). Produces exported outputs derived ONLY from confirmed d-chain fills.
TxExport
// TxRelayOrder forwards an opaque clob_* ZAP payload to the d-chain matcher,
// bound to a locked-collateral reference. Transport only — no local match.
TxRelayOrder
// TxPlaceOrder is a thin relay envelope for a CLOB limit-order placement.
TxPlaceOrder
// TxCancelOrder is a thin relay envelope for a CLOB cancel.
TxCancelOrder
)
func (t TxType) String() string {
switch t {
case TxImport:
return "import"
case TxExport:
return "export"
case TxRelayOrder:
return "relay_order"
case TxPlaceOrder:
return "place_order"
case TxCancelOrder:
return "cancel_order"
default:
return "unknown"
}
}
// Tx is the interface for all DEX proxy transactions.
type Tx interface {
// ID returns the unique identifier for this transaction.
ID() ids.ID
// Type returns the transaction type.
Type() TxType
// Sender returns the sender's address.
Sender() ids.ShortID
// Timestamp returns when the transaction was created.
Timestamp() int64
// Bytes returns the serialized transaction.
Bytes() []byte
// Verify validates the transaction.
Verify() error
}
// BaseTx contains common fields for all transactions.
//
// TxID is intentionally NOT serialized (json:"-"): the transaction ID is the
// SHA-256 checksum of the wire bytes, so embedding it in those same bytes would
// be circular and would make the ID depend on whatever value happened to be in
// the field at marshal time. It is always (re)derived from the wire on Parse,
// and stamped by finalize on construction.
type BaseTx struct {
TxID ids.ID `json:"-"`
TxType TxType `json:"type"`
From ids.ShortID `json:"from"`
Nonce uint64 `json:"nonce"`
GasPrice uint64 `json:"gasPrice"`
GasLimit uint64 `json:"gasLimit"`
CreatedAt int64 `json:"createdAt"`
Signature []byte `json:"signature"`
bytes []byte
}
func (tx *BaseTx) ID() ids.ID { return tx.TxID }
func (tx *BaseTx) Type() TxType { return tx.TxType }
func (tx *BaseTx) Sender() ids.ShortID { return tx.From }
func (tx *BaseTx) Timestamp() int64 { return tx.CreatedAt }
func (tx *BaseTx) Bytes() []byte { return tx.bytes }
// AtomicInput references a UTXO exported from the source chain that an Import
// consumes via shared memory. UTXOID is the source-chain UTXO id (the
// InputID()); Asset and Amount describe the value being claimed.
type AtomicInput struct {
UTXOID ids.ID `json:"utxoId"`
Asset ids.ID `json:"asset"`
Amount uint64 `json:"amount"`
}
// Rail is the cross-chain object's lane discriminator — the FIRST wire byte of the
// shared-memory object (atomic.go encodeExportedOutput). It is the H1-closing tag the
// precompile (precompile/dex/native_wire.go Rail) binds on the C side: a swap-fill
// object is RailSwap, an LP-collect object is RailLP, and each C-side consume path
// accepts ONLY its own rail (so a cross-rail consume can never reach the wrong pot).
// The proxy stamps it on every exported output and binds it on every import, so the
// rail round-trips through the atomic core unchanged.
type Rail uint8
const (
// RailSwap is the swap-fill / refund lane — the ZERO value, so an output whose rail
// is unstated (the proxy's settleFromFills fill/refund exports, every legacy
// custody/conservation path) defaults to the swap rail. The precompile's
// ImportSettlement consumes ONLY this rail.
RailSwap Rail = 0
// RailLP is the LP position-commit / collect lane — a non-zero tag the proxy's
// executeWithdraw stamps for an LP collect/withdraw. The precompile's
// ImportPositionCollect consumes ONLY this rail.
RailLP Rail = 1
)
// AtomicOutput is a value output: an Import credits these locally; an Export
// puts these into shared memory for the destination chain to claim. Rail is the lane
// the value travels (RailSwap default / RailLP), bound byte-for-byte into the
// shared-memory object so the precompile's matching consume path is the only one that
// can claim it.
type AtomicOutput struct {
Rail Rail `json:"rail,omitempty"`
Owner ids.ShortID `json:"owner"`
Asset ids.ID `json:"asset"`
Amount uint64 `json:"amount"`
// Spent is the matched INPUT amount (in the locked/input asset's units) that produced
// this output, set ONLY on a swap PROCEEDS leg (RailSwap, output asset != locked
// asset). It rides the shared-memory object so the C-side ImportSettlement can enforce
// the taker's OWN recorded slippage limit (proceeds >= spent * worstRate) INDEPENDENTLY
// of the keeper that built the relay — the taker-authenticated MEV/sandwich floor. It is
// 0 on a REFUND leg (output asset == locked asset; the per-taker principal cap governs
// that leg) and on every LP leg. It is informational to value conservation (the object's
// Amount alone moves value); it is the price-binding witness the proceeds floor checks.
Spent uint64 `json:"spent,omitempty"`
}
// ImportTx claims value exported from C-Chain (the SourceChain) into the proxy.
// The atomic shared-memory import leg: the imported UTXOs are removed from
// shared memory and their value credited to local outputs. This is the FIRST
// leg of the conservation ordering (import -> ZAP submit -> export).
type ImportTx struct {
BaseTx
SourceChain ids.ID `json:"sourceChain"`
ImportedInputs []AtomicInput `json:"importedInputs"`
Outputs []AtomicOutput `json:"outputs"`
}
// NewImportTx creates a new import transaction.
func NewImportTx(from ids.ShortID, nonce uint64, sourceChain ids.ID, in []AtomicInput, out []AtomicOutput) *ImportTx {
tx := &ImportTx{
BaseTx: BaseTx{
TxType: TxImport,
From: from,
Nonce: nonce,
GasPrice: 1000,
GasLimit: 100000,
CreatedAt: time.Now().UnixNano(),
},
SourceChain: sourceChain,
ImportedInputs: in,
Outputs: out,
}
return finalize(tx, &tx.BaseTx)
}
func (tx *ImportTx) Verify() error {
if tx.SourceChain == ids.Empty {
return errors.New("import: empty source chain")
}
if len(tx.ImportedInputs) == 0 {
return errors.New("import: no imported inputs")
}
// All consumed inputs name ONE asset (a deposit credits one (owner,asset) ledger
// row). The asset of the first input is the import's asset; every other input
// and every credited output must match it. This is the STRUCTURAL half of the
// native-aliasing bind (the AUTHORITATIVE half — input asset == the consumed
// UTXO's RECORDED asset — is enforced in executeImport against shared memory):
// together they pin output.Asset == input.Asset == recordedAsset, so an import
// can never credit an asset it does not actually consume.
importAsset := tx.ImportedInputs[0].Asset
var in uint64
for _, i := range tx.ImportedInputs {
if i.Amount == 0 {
return ErrInvalidAmount
}
if i.Asset != importAsset {
return errors.New("import: inputs span multiple assets")
}
in += i.Amount
}
var outAmt uint64
// All credited outputs share ONE rail (the lane the funding object travels). This
// is the STRUCTURAL half of the rail bind; the AUTHORITATIVE half (output rail ==
// the consumed UTXO's RECORDED rail) is enforced in executeImport against shared
// memory, exactly as for the asset axis.
var importRail Rail
if len(tx.Outputs) > 0 {
importRail = tx.Outputs[0].Rail
}
for _, o := range tx.Outputs {
if o.Asset != importAsset {
return errors.New("import: output asset != imported input asset (would re-denominate)")
}
if o.Rail != importRail {
return errors.New("import: outputs span multiple rails")
}
outAmt += o.Amount
}
// Conservation: credited outputs must not exceed claimed inputs (the proxy
// never mints). A difference is the burned fee.
if outAmt > in {
return errors.New("import: outputs exceed imported value (would mint)")
}
return nil
}
// ExportTx settles proceeds back to C-Chain (the DestinationChain). The atomic
// shared-memory export leg: ExportedOutputs are written into shared memory for
// the destination chain to claim. The amounts MUST be derived ONLY from
// confirmed d-chain fills (and any unfilled IOC remainder being refunded) — the
// proxy never mints. This is the FINAL leg of the conservation ordering.
type ExportTx struct {
BaseTx
DestinationChain ids.ID `json:"destinationChain"`
ExportedOutputs []AtomicOutput `json:"exportedOutputs"`
// FillRef binds this settlement to the d-chain relay receipt whose confirmed
// fills justify the exported amounts (replay-idempotency / audit).
FillRef ids.ID `json:"fillRef"`
}
// NewExportTx creates a new export transaction.
func NewExportTx(from ids.ShortID, nonce uint64, destChain ids.ID, out []AtomicOutput, fillRef ids.ID) *ExportTx {
tx := &ExportTx{
BaseTx: BaseTx{
TxType: TxExport,
From: from,
Nonce: nonce,
GasPrice: 1000,
GasLimit: 100000,
CreatedAt: time.Now().UnixNano(),
},
DestinationChain: destChain,
ExportedOutputs: out,
FillRef: fillRef,
}
return finalize(tx, &tx.BaseTx)
}
// NewSettlementExportTx builds the settlement export the VM constructs INSIDE
// block processing (settleFromFills) — never a client-submitted tx. Because it
// is reconstructed independently on every validator, its identity must be a
// pure function of consensus-agreed inputs only. The wall-clock CreatedAt used
// by the client-facing NewExportTx would make tx.ID() (and therefore any
// shared-memory UTXO key derived from it) diverge per node, splitting the
// atomic commit. Here CreatedAt is pinned to createdAt — the deterministic
// settlement coordinate (the relay's UnixNano block time) supplied by the
// caller — so the wire bytes, the TxID, and every derived export UTXO key are
// byte-identical across validators. Nonce carries the settlement's txIndex so
// two settlements in the same block produce distinct identities.
func NewSettlementExportTx(from ids.ShortID, txIndex uint32, destChain ids.ID, out []AtomicOutput, fillRef ids.ID, createdAt int64) *ExportTx {
tx := &ExportTx{
BaseTx: BaseTx{
TxType: TxExport,
From: from,
Nonce: uint64(txIndex),
GasPrice: 1000,
GasLimit: 100000,
CreatedAt: createdAt,
},
DestinationChain: destChain,
ExportedOutputs: out,
FillRef: fillRef,
}
return finalize(tx, &tx.BaseTx)
}
func (tx *ExportTx) Verify() error {
if tx.DestinationChain == ids.Empty {
return errors.New("export: empty destination chain")
}
if len(tx.ExportedOutputs) == 0 {
return errors.New("export: no exported outputs")
}
// All exported outputs share ONE rail — a single export leg settles on one lane
// (the precompile's matching consume path claims them). The fill/refund legs of a
// swap settle on RailSwap; an LP withdraw exports on RailLP.
exportRail := tx.ExportedOutputs[0].Rail
for _, o := range tx.ExportedOutputs {
if o.Amount == 0 {
return ErrInvalidAmount
}
if o.Rail != exportRail {
return errors.New("export: outputs span multiple rails")
}
}
return nil
}
// RelayOrderTx forwards an opaque clob_* ZAP payload to the d-chain matcher,
// bound to a locked-collateral reference. The payload is the byte-identical
// clob_submit/clob_place frame the d-chain's zap_server.go expects; the proxy
// is pure transport and does not interpret it beyond routing.
type RelayOrderTx struct {
BaseTx
// Method is the ZAP CLOB method ("clob_submit", "clob_place", "clob_cancel").
Method string `json:"method"`
// Payload is the opaque, byte-identical clob_* frame forwarded verbatim.
Payload []byte `json:"payload"`
// CollateralRef references the Import whose locked value backs this order.
CollateralRef ids.ID `json:"collateralRef"`
// AssetOut is the REAL injective output-asset id the taker receives for a settling
// clob_submit — the OPPOSITE side of the market this order trades on, the same
// assetID(currency_out) the C-side ImportSettlement requires (recAsset==claim.Asset,
// the id that keys seamReserve[assetOut]). The HIGH-1 fix: settleFromFills exports
// the PROCEEDS leg under THIS id, NOT a SHA256(ref||leg) routing handle (which never
// matched, so a swap's output was permanently unclaimable). It is keeper-asserted and
// signature-bound (JSON-covered by SigningBytes); a wrong value can only break the
// taker's OWN liveness — ImportSettlement equality-rejects a mismatched asset (no
// theft, no mint), the same bounded surface as the carried-fills proposer-trust model.
// PriceLimit is the worst-acceptable CLOB price (quote-per-base, uint64 FIXED-POINT
// ×PriceScale, the SAME grid as Fill.Price) for a settling clob_submit, derived from
// the V4 SqrtPriceLimitX96. settleFromFills refuses
// any carried fill WORSE than this (BUY: price above the limit; SELL: price below it),
// enforcing the taker's slippage floor against a sandwich/MEV move. Zero = no limit.
// 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"`
LimitIsUpper bool `json:"limitIsUpper,omitempty"`
}
// NewRelayOrderTx creates a new relay-order transaction (UNSIGNED). From carries no
// settle authority (the escrow owner does), so an unsigned relay is admissible; a
// client that wants authenticated From provenance calls Sign afterwards. assetOut is
// the real output-asset id a settling clob_submit credits (the HIGH-1 fix); it is
// ids.Empty for non-settling place/cancel relays. priceLimit/limitIsUpper carry the
// taker's slippage floor (0 = none).
func NewRelayOrderTx(from ids.ShortID, nonce uint64, method string, payload []byte, collateralRef ids.ID) *RelayOrderTx {
tx := &RelayOrderTx{
BaseTx: BaseTx{
TxType: TxRelayOrder,
From: from,
Nonce: nonce,
GasPrice: 1000,
GasLimit: 100000,
CreatedAt: time.Now().UnixNano(),
},
Method: method,
Payload: payload,
CollateralRef: collateralRef,
}
return finalize(tx, &tx.BaseTx)
}
// NewSettlingRelayOrderTx builds a settling clob_submit relay carrying the output-asset
// id + slippage limit the proxy settles against. The keeper (which knows the market)
// populates assetOut (the opposite side of the market) and the price limit derived from
// the taker's V4 SqrtPriceLimitX96. This is the canonical constructor for the settling
// rail; non-settling relays use NewRelayOrderTx (assetOut empty).
func NewSettlingRelayOrderTx(from ids.ShortID, nonce uint64, payload []byte, collateralRef, assetOut ids.ID, priceLimit uint64, limitIsUpper bool) *RelayOrderTx {
tx := NewRelayOrderTx(from, nonce, "clob_submit", payload, collateralRef)
tx.AssetOut = assetOut
tx.PriceLimit = priceLimit
tx.LimitIsUpper = limitIsUpper
return finalize(tx, &tx.BaseTx)
}
func (tx *RelayOrderTx) Verify() error {
if tx.Method == "" {
return errors.New("relay: empty ZAP method")
}
if len(tx.Payload) == 0 {
return errors.New("relay: empty payload")
}
// PROVENANCE AUTHENTICATION of the From field. From carries NO settle authority —
// the settle authority + payout target derive from the consumed C->D object's
// recorded owner (the escrow owner persisted at import; see chains/dexvm
// settleFromFills), exactly as platformvm import/export authority comes from the
// consumed UTXO, not a tx-level identity. So an UNSIGNED relay cannot escalate: it
// can only settle to whatever escrow its CollateralRef names, and that escrow pays
// its recorded owner regardless of From. The signature, WHEN PRESENT, cryptographi-
// cally binds From (a secp256k1/EVM-address recovery over the unsigned wire image),
// giving the routing/audit layer authenticated provenance and wiring the otherwise-
// dangling ErrInvalidSignature. A present-but-invalid signature is rejected at
// admission; an absent one is permitted (authority lives in the escrow bind).
if len(tx.Signature) > 0 {
if err := tx.verifyFrom(); err != nil {
return err
}
}
return nil
}
// 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 (native ZAP
// fixed-offset wire; no maps, list order preserved).
func (tx *RelayOrderTx) SigningBytes() ([]byte, error) {
unsigned := *tx
unsigned.BaseTx.Signature = nil
unsigned.BaseTx.bytes = nil
unsigned.BaseTx.TxID = ids.Empty
return Marshal(&unsigned, TxRelayOrder)
}
// Sign stamps the relay with a secp256k1 signature over SigningBytes by `key`, and
// sets From to key's EVM address (the owner format the C->D object carries). After
// this, Verify authenticates From cryptographically. Used by clients/keepers that
// build relays; the proxy itself never signs (it derives authority from escrow).
func (tx *RelayOrderTx) Sign(key *secp256k1.PrivateKey) error {
tx.From = key.EVMAddress()
msg, err := tx.SigningBytes()
if err != nil {
return err
}
sig, err := key.SignHash(hashRelaySigningBytes(msg))
if err != nil {
return err
}
tx.Signature = sig
// Re-stamp the wire bytes + TxID now that From + Signature are set, so the signed
// tx is immediately wire-ready and Parse-round-trippable (same as every New*Tx).
finalize(tx, &tx.BaseTx)
return nil
}
// verifyFrom recovers the signer of the relay from its signature over the unsigned
// wire image and requires the recovered EVM address to equal tx.From. A mismatch (or
// an unrecoverable signature) is ErrInvalidSignature — a spoofed From is refused at
// admission.
func (tx *RelayOrderTx) verifyFrom() error {
msg, err := tx.SigningBytes()
if err != nil {
return err
}
pub, err := secp256k1.RecoverPublicKeyFromHash(hashRelaySigningBytes(msg), tx.Signature)
if err != nil {
return ErrInvalidSignature
}
if ids.ShortID(pub.EVMAddress()) != tx.From {
return ErrInvalidSignature
}
return nil
}
// hashRelaySigningBytes is the single hash the relay sign+recover paths share, so
// signing and verification commit to the identical digest of the unsigned wire image.
func hashRelaySigningBytes(msg []byte) []byte {
h := sha256.Sum256(msg)
return h[:]
}
// PlaceOrderTx is a thin relay envelope for a CLOB limit-order placement. It
// carries the wire fields a clob_place frame needs but NO matching logic — the
// VM forwards it to the d-chain and settles from the returned ack/fills.
type PlaceOrderTx struct {
BaseTx
// PoolID is the 32-byte market identity (V4 poolId) the d-chain keys by.
PoolID [32]byte `json:"poolId"`
Side uint8 `json:"side"` // 0 = Buy/bid, 1 = Sell/ask
Price uint64 `json:"price"`
Size uint64 `json:"size"`
// CollateralRef references the Import whose locked value backs this order.
CollateralRef ids.ID `json:"collateralRef"`
}
// NewPlaceOrderTx creates a new place-order relay envelope.
func NewPlaceOrderTx(from ids.ShortID, nonce uint64, poolID [32]byte, side uint8, price, size uint64) *PlaceOrderTx {
tx := &PlaceOrderTx{
BaseTx: BaseTx{
TxType: TxPlaceOrder,
From: from,
Nonce: nonce,
GasPrice: 1000,
GasLimit: 100000,
CreatedAt: time.Now().UnixNano(),
},
PoolID: poolID,
Side: side,
Price: price,
Size: size,
}
return finalize(tx, &tx.BaseTx)
}
func (tx *PlaceOrderTx) Verify() error {
if tx.Size == 0 {
return ErrInvalidAmount
}
if tx.Price == 0 {
return ErrInvalidPrice
}
return nil
}
// CancelOrderTx is a thin relay envelope for a CLOB cancel. The d-chain
// authenticates the cancel against the resting order's maker — the proxy only
// forwards the (market, orderID) reference.
type CancelOrderTx struct {
BaseTx
PoolID [32]byte `json:"poolId"`
OrderID uint64 `json:"orderId"`
}
// NewCancelOrderTx creates a new cancel-order relay envelope.
func NewCancelOrderTx(from ids.ShortID, nonce uint64, poolID [32]byte, orderID uint64) *CancelOrderTx {
tx := &CancelOrderTx{
BaseTx: BaseTx{
TxType: TxCancelOrder,
From: from,
Nonce: nonce,
GasPrice: 1000,
GasLimit: 50000,
CreatedAt: time.Now().UnixNano(),
},
PoolID: poolID,
OrderID: orderID,
}
return finalize(tx, &tx.BaseTx)
}
func (tx *CancelOrderTx) Verify() error {
if tx.OrderID == 0 {
return errors.New("cancel: order id cannot be zero")
}
return nil
}
// TxParser parses raw transaction bytes.
type TxParser struct{}
// Parse parses a transaction from bytes.
func (p *TxParser) Parse(data []byte) (Tx, error) {
if len(data) < 1 {
return nil, ErrInvalidTxType
}
txType := TxType(data[0])
switch txType {
case TxImport:
return parseImportTx(data)
case TxExport:
return parseExportTx(data)
case TxRelayOrder:
return parseRelayOrderTx(data)
case TxPlaceOrder:
return parsePlaceOrderTx(data)
case TxCancelOrder:
return parseCancelOrderTx(data)
default:
return nil, ErrInvalidTxType
}
}
// 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.
func stampBase(tx any, txType TxType, data []byte) {
type baseHolder interface{ base() *BaseTx }
if h, ok := tx.(baseHolder); ok {
b := h.base()
b.TxType = txType
b.TxID = ids.Checksum256(data)
b.bytes = data
}
}
func (tx *BaseTx) base() *BaseTx { return tx }
// 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:] = ZAP message
//
// 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: 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) {
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
}
}
// finalize serializes the constructed transaction and stamps its wire bytes and
// deterministic TxID. Every New*Tx constructor calls this so a freshly built
// transaction is immediately wire-ready and Parse-round-trippable.
func finalize[T any](tx *T, base *BaseTx) *T {
wire, err := Marshal(tx, base.TxType)
if err != nil {
// 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)
base.bytes = wire
return tx
}
-479
View File
@@ -1,479 +0,0 @@
// 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
@@ -1,371 +0,0 @@
// 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)
}
}
}
-1545
View File
File diff suppressed because it is too large Load Diff
+239
View File
@@ -0,0 +1,239 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package slot pins which VM plugin slots this module is allowed to claim.
//
// luxd loads a VM by executing the binary at <PluginDir>/<VMID>; the VMID alone
// decides which chain that binary drives. A slot admits exactly one artifact, so
// a second binary built for the same slot is not a spare — it is a coin flip
// settled by whichever image wrote the file last.
//
// The D-Chain slot is the case that motivated this package. Its id is the ASCII
// name "dexvm" zero-padded to 32 bytes, and the artifact that fills it is
// luxfi/dex cmd/dexd — the matcher VM that runs the order book inside consensus.
// This module used to ship a SECOND binary for that same slot: a proxy whose wire
// spoke clob_* while the venue answered dex_*. It loaded, reported healthy, and
// answered nothing — a failure that reads as a working node. The proxy is gone;
// these tests keep it gone.
//
// The checks read the AST rather than the file text, so they judge what the code
// DECLARES. A comment may discuss the D slot or the dead wire freely; only a
// declaration claims them.
package slot
import (
"encoding/json"
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/luxfi/ids"
)
// dSlot is the canonical D-Chain VM id, derived rather than pasted: a chain's
// VMID is its name in ASCII, zero-padded to 32 bytes.
var dSlot = func() ids.ID {
var id ids.ID
copy(id[:], "dexvm")
return id
}()
// moduleRoot is this module's root directory (two levels up from internal/slot).
const moduleRoot = "../.."
// eachFile parses every .go file in the module and hands the AST to visit, keyed
// by module-relative path. Vendored trees and build output are skipped: neither
// is source this module ships.
func eachFile(t *testing.T, visit func(path string, file *ast.File)) {
t.Helper()
fset := token.NewFileSet()
n := 0
err := filepath.WalkDir(moduleRoot, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
switch d.Name() {
case ".git", "vendor", "build", "node_modules", "testdata":
return filepath.SkipDir
}
// This package carries the patterns it looks for; a checker does not
// judge its own pattern table.
if path == filepath.Join(moduleRoot, "internal", "slot") {
return filepath.SkipDir
}
return nil
}
if filepath.Ext(path) != ".go" {
return nil
}
f, perr := parser.ParseFile(fset, path, nil, parser.SkipObjectResolution)
if perr != nil {
return perr
}
rel, rerr := filepath.Rel(moduleRoot, path)
if rerr != nil {
return rerr
}
n++
visit(rel, f)
return nil
})
if err != nil {
t.Fatalf("parsing module source: %v", err)
}
if n == 0 {
t.Fatal("parsed no .go files — the walk is broken, not the invariant")
}
}
// vmIDLiteral reports the VM id a composite literal declares, for literals of the
// form ids.ID{...}. Elements are read as untyped byte constants, so a claim spelled
// in characters, decimals or hex is recognised the same way. ok is false for any
// literal that is not a fully constant ids.ID.
func vmIDLiteral(node ast.Node) (id ids.ID, ok bool) {
lit, isComposite := node.(*ast.CompositeLit)
if !isComposite {
return id, false
}
sel, isSel := lit.Type.(*ast.SelectorExpr)
if !isSel || sel.Sel.Name != "ID" {
return id, false
}
pkg, isIdent := sel.X.(*ast.Ident)
if !isIdent || pkg.Name != "ids" {
return id, false
}
if len(lit.Elts) > len(id) {
return id, false
}
for i, elt := range lit.Elts {
b, isBasic := elt.(*ast.BasicLit)
if !isBasic {
return id, false // not a constant claim; nothing to judge
}
switch b.Kind {
case token.CHAR:
r, _, _, uerr := strconv.UnquoteChar(strings.Trim(b.Value, "'"), '\'')
if uerr != nil || r > 0xFF {
return id, false
}
id[i] = byte(r)
case token.INT:
v, cerr := strconv.ParseUint(b.Value, 0, 8)
if cerr != nil {
return id, false
}
id[i] = byte(v)
default:
return id, false
}
}
return id, true
}
// wireMethod reports whether s is shaped like a wire method name rather than
// prose that happens to mention one. A method name is a bare lowercase token; a
// log line or format string is not, and flagging those would train readers to
// ignore this check.
func wireMethod(s string) bool {
for _, r := range s {
if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '_' {
return false
}
}
return s != ""
}
// stringLit returns the value of a string literal node.
func stringLit(node ast.Node) (string, bool) {
b, ok := node.(*ast.BasicLit)
if !ok || b.Kind != token.STRING {
return "", false
}
v, err := strconv.Unquote(b.Value)
if err != nil {
return "", false
}
return v, true
}
// TestClaimsNoDChainSlot asserts no package in this module declares the D-Chain
// VM id. The D VM is not here: it is luxfi/dex cmd/dexd, which the node Dockerfile
// builds straight into the slot. A declaration of this id inside chains is a
// package positioning itself to be installed over the real matcher.
//
// Both spellings are checked because both are installable: the byte literal is how
// Go code claims the id, and the CB58 form names the plugin FILE on disk.
func TestClaimsNoDChainSlot(t *testing.T) {
cb58 := dSlot.String()
eachFile(t, func(path string, f *ast.File) {
ast.Inspect(f, func(n ast.Node) bool {
if id, ok := vmIDLiteral(n); ok && id == dSlot {
t.Errorf("%s declares the canonical D-Chain VM id. That slot belongs to "+
"luxfi/dex cmd/dexd; a second artifact for it replaces the matcher "+
"with whatever this package serves", path)
}
if s, ok := stringLit(n); ok && s == cb58 {
t.Errorf("%s names the D-Chain plugin file %s. That path is the install "+
"target of the real matcher", path, cb58)
}
return true
})
})
}
// TestCarriesNoDeadDexWire asserts this module declares no clob_* wire method.
//
// A VM in the D slot is reachable only through the names it answers. The venue
// registers dex_* (luxfi/dex pkg/zapwire); clob_* was the deleted proxy's wire and
// matches nothing on either side. Names that match nothing are precisely what made
// the proxy dangerous: the node loads, health passes, and every order is a no-op.
func TestCarriesNoDeadDexWire(t *testing.T) {
eachFile(t, func(path string, f *ast.File) {
ast.Inspect(f, func(n ast.Node) bool {
if s, ok := stringLit(n); ok && strings.HasPrefix(s, "clob_") && wireMethod(s) {
t.Errorf("%s declares the wire method %q. Nothing answers that namespace — "+
"the venue registers dex_*, so this name reaches no matcher", path, s)
}
return true
})
})
}
// TestPublishesNoDChainImage asserts the release matrix ships no image for the
// D-Chain slot. .github/images.json is the whole publish plan: release.yml maps
// each entry to `go build ./<vm>/cmd/plugin` and pushes it as an image named for
// the vm. An entry here is not a latent risk — it is CI handing operators a binary
// that lands in the canonical slot on install.
func TestPublishesNoDChainImage(t *testing.T) {
raw, err := os.ReadFile(filepath.Join(moduleRoot, ".github", "images.json"))
if err != nil {
t.Fatalf("reading the release image matrix: %v", err)
}
var images []struct {
VM string `json:"vm"`
Image string `json:"image"`
}
if err := json.Unmarshal(raw, &images); err != nil {
t.Fatalf("parsing the release image matrix: %v", err)
}
if len(images) == 0 {
t.Fatal("the release image matrix parsed empty — the parse is broken, not the invariant")
}
// The slot's identity IS its name: the VMID is "dexvm" in ASCII, and release.yml
// names both the build target and the published image after that string.
slotName := strings.TrimRight(string(dSlot[:]), "\x00")
for _, img := range images {
if img.VM == slotName {
t.Errorf("the release matrix publishes %q as %s — that binary loads into the "+
"canonical D-Chain slot %s, which luxfi/dex cmd/dexd already fills",
img.VM, img.Image, dSlot)
}
}
}