mirror of
https://github.com/luxfi/chains.git
synced 2026-09-24 00:26:00 +00:00
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:
@@ -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"},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user