Files
chains/dexvm/redteam_swap_proceeds_test.go
zeekayandHanzo Dev d92afe4f45 fix(dexvm): exact-integer ZAP wire — match dex v1.14.0, kill cross-arch fork
The dex venue (v1.14.0) froze its ZAP wire to EXACT INTEGERS: order/fill
price is uint64 fixed-point ×1e8 (the PriceInt grid), size is uint64 atomic
base units — no IEEE-754 float64. This eliminated every value-bearing
float→int conversion from the settlement path, whose out-of-range result is
implementation-defined per the Go spec and differs by CPU arch (arm64
saturates, amd64 wraps) — the cross-arch consensus fork (F1-F5).

The stateless atomic ZAP proxy still encoded/decoded the OLD float wire, so
it could no longer interoperate with the new venue. This propagates the
frozen frame byte-identically:

- relay.go: Fill{Price,Size} float64 -> uint64; DecodeFills reads big-endian
  uint64s and rejects zero price/size. New PriceScale const == zapwire.PriceScale.
- carried_fills.go: block-carried fills encode/decode as exact uint64s.
- atomic.go: drop the float→int rounding machinery (quantToCredit/quantToCharge/
  nearestIntWithin/settlementRoundEpsilon/maxSettlementUnit/float64FromBits/
  isFinitePositive). Add quoteUnits (floor(size×price/PriceScale) in big.Int,
  mirroring dex v4BalanceDelta / the matcher's quoteUnitsFor) and settleUint64
  (refuses an aggregate that exceeds uint64 rather than wrapping).
- vm.go settleFromFills: aggregate base=Σsize, quote=Σ per-fill floor as exact
  integers; slippage limit compared as fixed-point uint64; no float. Refund/
  proceeds are the venue's own integers — escrow-truncation mint is structurally
  impossible; spent>locked still refused.
- vm.go encodeCLOBPlace: price = tx.Price×PriceScale, size passthrough (no float).
- feegate.go: swapNativeFeeToLUX takes a fixed-point Fill.Price and floors via
  quoteUnits.
- txs/tx.go: RelayOrderTx.PriceLimit is fixed-point ×PriceScale (was float64-bits).

Dep: github.com/luxfi/dex v1.5.20 (indirect) -> v1.14.0 (direct, test imports
the pure-Go zapwire leaf as the parity oracle).

Tests: all conservation/atomicity/redteam tests translated to the integer
semantics WITHOUT weakening any invariant (the no-mint / overflow-refusal /
mixed-side / slippage / determinism proofs are preserved, re-anchored to the
venue's exact per-fill floor). New frame_parity_test.go asserts the proxy's
Place/Submit/Fill frames are BYTE-IDENTICAL to dex v1.14.0 zapwire.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-02 10:40:05 -07:00

289 lines
13 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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")
}