mirror of
https://github.com/luxfi/chains.git
synced 2026-09-24 00:26:00 +00:00
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 <[email protected]>
This commit is contained in:
+34
-91
@@ -7,7 +7,7 @@ import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"hash"
|
||||
"math"
|
||||
"math/big"
|
||||
"sort"
|
||||
|
||||
"github.com/luxfi/database"
|
||||
@@ -413,102 +413,45 @@ func decodeExportedOutput(v []byte) (rail txs.Rail, owner ids.ShortID, asset ids
|
||||
return rail, owner, asset, amount, spent, true
|
||||
}
|
||||
|
||||
// float64FromBits reads a big-endian IEEE-754 float64 (the ZAP fill wire codec,
|
||||
// byte-identical with dex/pkg/api and the precompile).
|
||||
func float64FromBits(b []byte) float64 {
|
||||
return math.Float64frombits(binary.BigEndian.Uint64(b))
|
||||
}
|
||||
|
||||
// isFinitePositive reports whether f is a finite, strictly positive number — the
|
||||
// only values a real fill can carry. Rejects NaN/Inf/<=0.
|
||||
func isFinitePositive(f float64) bool {
|
||||
return !math.IsNaN(f) && !math.IsInf(f, 0) && f > 0
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Conservation-safe float->integer rounding for settlement.
|
||||
// Exact-integer settlement arithmetic.
|
||||
//
|
||||
// Fills cross the ZAP wire as float64 (price, size); on-chain value moves in
|
||||
// integer asset units (uint64). Converting a fractional notional to an integer
|
||||
// MUST round in the direction that protects the conservation invariant — the
|
||||
// proxy NEVER mints — so the rounding is asymmetric BY PURPOSE:
|
||||
//
|
||||
// - quantToCredit (FLOOR): a quantity the taker RECEIVES (proceeds) or that
|
||||
// REDUCES what we owe back. Round DOWN so the proxy never credits a unit it
|
||||
// did not truly realize. Worst case the taker is under-credited by <1 unit;
|
||||
// that sub-unit is conserved on the d-chain side (the maker's leg), never
|
||||
// fabricated out of the proxy.
|
||||
// - quantToCharge (CEIL): a quantity the taker SPENDS against locked escrow
|
||||
// (so refund = locked - spent). Round UP so spent is never UNDERstated.
|
||||
// Understating spent is exactly the escrow-truncation mint (RED): it inflates
|
||||
// the refund, letting the taker walk away with proceeds PLUS more refund than
|
||||
// the unspent remainder. Ceiling spent caps refund at the true unspent value.
|
||||
//
|
||||
// Both take an EXACT float aggregate (summed over fills once, not per-fill
|
||||
// truncated — per-fill truncation accumulates a directional leak) and apply the
|
||||
// rounding ONCE at the asset boundary. A small relative epsilon absorbs IEEE-754
|
||||
// representation error so an aggregate that is mathematically integral (e.g.
|
||||
// 1.5*3 + 2.5*3 == 12, but may evaluate to 12 ± 1e-13) snaps to that integer
|
||||
// instead of spuriously rounding to 11 or 13. epsilon is deterministic (a fixed
|
||||
// constant over deterministic float inputs) so every validator rounds identically.
|
||||
// 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.
|
||||
|
||||
// settlementRoundEpsilon is the relative tolerance for snapping a float aggregate
|
||||
// to a neighboring integer before directional rounding. ~1e-9 dwarfs the ~1e-13
|
||||
// double-rounding error of summing realistic fill streams yet is far below one
|
||||
// asset unit, so it never moves a genuinely fractional notional across an integer.
|
||||
const settlementRoundEpsilon = 1e-9
|
||||
// priceScaleBig is PriceScale as a *big.Int (the fixed-point divisor for quote).
|
||||
var priceScaleBig = new(big.Int).SetUint64(PriceScale)
|
||||
|
||||
// nearestIntWithin returns (n, true) when f is within a relative epsilon of the
|
||||
// integer n, else (0, false). Used to snap a mathematically-integral aggregate
|
||||
// back to its integer before floor/ceil.
|
||||
func nearestIntWithin(f float64) (float64, bool) {
|
||||
r := math.Round(f)
|
||||
tol := settlementRoundEpsilon * math.Max(1, math.Abs(f))
|
||||
if math.Abs(f-r) <= tol {
|
||||
return r, true
|
||||
}
|
||||
return 0, false
|
||||
// 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)
|
||||
}
|
||||
|
||||
// quantToCredit converts a proceeds aggregate to integer asset units, rounding
|
||||
// DOWN (never credit more than realized). Non-finite or negative input, or a
|
||||
// result exceeding uint64, is refused — the proxy must not settle a value it
|
||||
// cannot represent exactly.
|
||||
func quantToCredit(f float64) (uint64, error) {
|
||||
if math.IsNaN(f) || math.IsInf(f, 0) || f < 0 {
|
||||
return 0, fmt.Errorf("settle: non-finite proceeds %v", f)
|
||||
// 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 r, ok := nearestIntWithin(f); ok {
|
||||
f = r
|
||||
} else {
|
||||
f = math.Floor(f)
|
||||
if !v.IsUint64() {
|
||||
return 0, fmt.Errorf("settle: %s %s exceeds uint64", label, v.String())
|
||||
}
|
||||
if f > maxSettlementUnit {
|
||||
return 0, fmt.Errorf("settle: proceeds %v exceeds uint64", f)
|
||||
}
|
||||
return uint64(f), nil
|
||||
return v.Uint64(), nil
|
||||
}
|
||||
|
||||
// quantToCharge converts a spent aggregate to integer asset units, rounding UP
|
||||
// (never charge less than consumed, so the refund is never inflated). Same
|
||||
// finiteness / range guards as quantToCredit.
|
||||
func quantToCharge(f float64) (uint64, error) {
|
||||
if math.IsNaN(f) || math.IsInf(f, 0) || f < 0 {
|
||||
return 0, fmt.Errorf("settle: non-finite notional %v", f)
|
||||
}
|
||||
if r, ok := nearestIntWithin(f); ok {
|
||||
f = r
|
||||
} else {
|
||||
f = math.Ceil(f)
|
||||
}
|
||||
if f > maxSettlementUnit {
|
||||
return 0, fmt.Errorf("settle: notional %v exceeds uint64", f)
|
||||
}
|
||||
return uint64(f), nil
|
||||
}
|
||||
|
||||
// maxSettlementUnit is the largest float64 that converts to uint64 without
|
||||
// overflow on rounding. 2^64 is not exactly representable; the largest float64
|
||||
// strictly below it is 2^64 - 2048. Using <= against this constant keeps the
|
||||
// uint64() conversion in-range and deterministic.
|
||||
const maxSettlementUnit = float64(1<<64 - 2048)
|
||||
|
||||
@@ -96,7 +96,7 @@ func settleByzantineFills(t *testing.T, fills []Fill, lockedQuote uint64) (taker
|
||||
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: 2, Size: 600, Side: 0}}
|
||||
fills := []Fill{{Price: fp(2), Size: 600, Side: 0}}
|
||||
|
||||
_, baseAsset, quoteAsset, legs := settleByzantineFills(t, fills, lockedQuote)
|
||||
|
||||
@@ -127,7 +127,7 @@ func TestByzantineVenue_OverNotionalAttestedFillCannotMint(t *testing.T) {
|
||||
func TestByzantineVenue_WithinCollateralAttestedFillSettles(t *testing.T) {
|
||||
const lockedQuote uint64 = 1000
|
||||
// BUY: receives base = 400, spends quote = 400*2 = 800 <= 1000 locked.
|
||||
fills := []Fill{{Price: 2, Size: 400, Side: 0}}
|
||||
fills := []Fill{{Price: fp(2), Size: 400, Side: 0}}
|
||||
|
||||
_, baseAsset, quoteAsset, legs := settleByzantineFills(t, fills, lockedQuote)
|
||||
|
||||
@@ -153,7 +153,7 @@ func TestByzantineVenue_WithinCollateralAttestedFillSettles(t *testing.T) {
|
||||
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: 2, Size: 100, Side: 0}, {Price: 2, Size: 300, Side: 1}}
|
||||
fills := []Fill{{Price: fp(2), Size: 100, Side: 0}, {Price: fp(2), Size: 300, Side: 1}}
|
||||
|
||||
_, baseAsset, quoteAsset, legs := settleByzantineFills(t, fills, lockedQuote)
|
||||
|
||||
@@ -181,8 +181,8 @@ func TestByzantineVenue_MixedSideAttestedFillRefused(t *testing.T) {
|
||||
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: 3, Size: 100, Side: 0}, {Price: 2, Size: 150, Side: 0}}
|
||||
fillsBA := []Fill{{Price: 2, Size: 150, Side: 0}, {Price: 3, Size: 100, Side: 0}}
|
||||
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)
|
||||
|
||||
+14
-14
@@ -6,7 +6,6 @@ package dexvm
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
// carried_fills.go — the CARRIED-FILLS primitive of the stateless atomic ZAP
|
||||
@@ -51,8 +50,9 @@ import (
|
||||
// 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) is UNCHANGED;
|
||||
// the per-fill encoding here reuses the same price[8]+size[8]+side[1] layout for
|
||||
// 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
|
||||
@@ -109,8 +109,8 @@ func encodeCarriedFills(entries []carriedFill, sig []byte) []byte {
|
||||
binary.BigEndian.PutUint32(buf[off:], uint32(len(e.fills)))
|
||||
off += 4
|
||||
for _, f := range e.fills {
|
||||
binary.BigEndian.PutUint64(buf[off:off+8], math.Float64bits(f.Price))
|
||||
binary.BigEndian.PutUint64(buf[off+8:off+16], math.Float64bits(f.Size))
|
||||
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
|
||||
}
|
||||
@@ -125,9 +125,9 @@ func encodeCarriedFills(entries []carriedFill, sig []byte) []byte {
|
||||
// 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 finite and strictly positive and side ∈
|
||||
// {0,1} (the SAME boundary invariant DecodeFills enforces on the ZAP wire),
|
||||
// so no impossible Fill ever reaches settleFromFills;
|
||||
// - 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.
|
||||
//
|
||||
@@ -161,15 +161,15 @@ func decodeCarriedFills(data []byte) (entries []carriedFill, sig []byte, consume
|
||||
}
|
||||
fills := make([]Fill, 0, fc)
|
||||
for j := 0; j < fc; j++ {
|
||||
p := float64FromBits(data[off : off+8])
|
||||
s := float64FromBits(data[off+8 : off+16])
|
||||
p := binary.BigEndian.Uint64(data[off : off+8])
|
||||
s := binary.BigEndian.Uint64(data[off+8 : off+16])
|
||||
side := data[off+16]
|
||||
off += FillWireSize
|
||||
if !isFinitePositive(p) {
|
||||
return nil, nil, 0, fmt.Errorf("carried fills: entry %d fill %d invalid price %v", i, j, p)
|
||||
if p == 0 {
|
||||
return nil, nil, 0, fmt.Errorf("carried fills: entry %d fill %d invalid price %d", i, j, p)
|
||||
}
|
||||
if !isFinitePositive(s) {
|
||||
return nil, nil, 0, fmt.Errorf("carried fills: entry %d fill %d invalid size %v", i, j, s)
|
||||
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)
|
||||
|
||||
@@ -26,9 +26,9 @@ import (
|
||||
// representative entry set (multiple txIndexes, multiple fills, a zero-fill entry).
|
||||
func TestCarriedFills_CodecRoundTrip(t *testing.T) {
|
||||
in := []carriedFill{
|
||||
{txIndex: 1, fills: []Fill{{Price: 2, Size: 50, Side: 0}, {Price: 2, Size: 50, Side: 0}}},
|
||||
{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: 1.5, Size: 4, Side: 1}}},
|
||||
{txIndex: 7, fills: []Fill{{Price: fp(1.5), Size: 4, Side: 1}}},
|
||||
}
|
||||
enc := encodeCarriedFills(in, nil)
|
||||
out, sig, consumed, err := decodeCarriedFills(enc)
|
||||
@@ -72,7 +72,7 @@ func TestCarriedFills_CodecRoundTrip(t *testing.T) {
|
||||
// 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: 1, Size: 1, Side: 0}}}}, sigIn)
|
||||
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)
|
||||
@@ -90,7 +90,7 @@ func TestCarriedFills_ReservedSignatureRoundTrips(t *testing.T) {
|
||||
// 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: 1, Size: 1, Side: 0}}}}, nil)
|
||||
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 {
|
||||
@@ -145,7 +145,7 @@ func TestBlock_CarriedFillsRoundTripAndIDBinding(t *testing.T) {
|
||||
timestamp: time.Unix(1_700_000_000, 0),
|
||||
txs: [][]byte{[]byte("tx-a"), []byte("tx-bb")},
|
||||
carriedFills: []carriedFill{
|
||||
{txIndex: 1, fills: []Fill{{Price: 2, Size: 50, Side: 0}}},
|
||||
{txIndex: 1, fills: []Fill{{Price: fp(2), Size: 50, Side: 0}}},
|
||||
},
|
||||
}
|
||||
wire := b.Bytes()
|
||||
@@ -165,7 +165,7 @@ func TestBlock_CarriedFillsRoundTripAndIDBinding(t *testing.T) {
|
||||
// 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: 2, Size: 999, Side: 0}}}}
|
||||
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 {
|
||||
@@ -185,7 +185,7 @@ func TestVertex_CarriedFillsRoundTripAndIDBinding(t *testing.T) {
|
||||
parents: []ids.ID{{0x01}},
|
||||
rawTxs: [][]byte{[]byte("raw-tx-1")},
|
||||
carriedFills: []carriedFill{
|
||||
{txIndex: 0, fills: []Fill{{Price: 3, Size: 9, Side: 1}}},
|
||||
{txIndex: 0, fills: []Fill{{Price: fp(3), Size: 9, Side: 1}}},
|
||||
},
|
||||
vm: cvm,
|
||||
}
|
||||
@@ -212,7 +212,7 @@ func TestVertex_CarriedFillsRoundTripAndIDBinding(t *testing.T) {
|
||||
timestamp: v.timestamp,
|
||||
parents: v.parents,
|
||||
rawTxs: v.rawTxs,
|
||||
carriedFills: []carriedFill{{txIndex: 0, fills: []Fill{{Price: 3, Size: 9999, Side: 1}}}},
|
||||
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])
|
||||
|
||||
@@ -28,7 +28,7 @@ package dexvm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"math/big"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -96,31 +96,20 @@ func settledRound(t *testing.T, fills []Fill, collateral uint64, takerSide uint8
|
||||
// requires be refunded.
|
||||
//
|
||||
// It mirrors settleFromFills exactly: the spent quantity is aggregated over the
|
||||
// fills as an exact float and rounded UP (ceiling) ONCE — the conservation-safe
|
||||
// direction for a charge, so the refund (locked - spent) is never inflated. (The
|
||||
// prior per-fill uint64 truncation was the escrow-extraction bug.)
|
||||
// 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 {
|
||||
var n float64
|
||||
total := new(big.Int)
|
||||
for _, f := range fills {
|
||||
if takerSide == 0 { // BUY locked quote; quote consumed = price*size
|
||||
n += f.Price * f.Size
|
||||
} else { // SELL locked base; base consumed = size
|
||||
n += f.Size
|
||||
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 ceilToUnit(n)
|
||||
}
|
||||
|
||||
// ceilToUnit rounds a non-negative float aggregate UP to integer asset units,
|
||||
// snapping a mathematically-integral aggregate (e.g. 1.5*3+2.5*3 == 12, but may
|
||||
// evaluate to 12 ± 1e-13) to that integer first. It mirrors atomic.go's
|
||||
// quantToCharge so the tests reason in the SAME arithmetic the proxy uses.
|
||||
func ceilToUnit(f float64) uint64 {
|
||||
r := math.Round(f)
|
||||
if math.Abs(f-r) <= 1e-9*math.Max(1, math.Abs(f)) {
|
||||
return uint64(r)
|
||||
}
|
||||
return uint64(math.Ceil(f))
|
||||
return total.Uint64()
|
||||
}
|
||||
|
||||
// TestConservationPartialFillBuy is the decisive partial-settlement proof. The
|
||||
@@ -132,7 +121,7 @@ func ceilToUnit(f float64) uint64 {
|
||||
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: 2, Size: 200}}
|
||||
fills := []Fill{{Price: fp(2), Size: 200}}
|
||||
const takerSide uint8 = 0 // BUY
|
||||
|
||||
valueIn, valueOut := settledRound(t, fills, lockedQuote, takerSide)
|
||||
@@ -192,7 +181,7 @@ func TestConservationZeroFill(t *testing.T) {
|
||||
// must be proceeds(1500 quote) + refund(700 base) = 2200.
|
||||
func TestConservationPartialFillSell(t *testing.T) {
|
||||
const lockedBase uint64 = 1000
|
||||
fills := []Fill{{Price: 5, Size: 300}} // 300 base sold, 1500 quote received
|
||||
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)
|
||||
@@ -298,41 +287,40 @@ func TestFailedRelayCommitsNothing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestConservationRoundingBoundary probes the float64->uint64 seam in the
|
||||
// spent/refund split. settleFromFills aggregates the quote notional over the
|
||||
// fills as an exact float and rounds the SPENT side UP (ceiling) ONCE — the
|
||||
// conservation-safe direction so the refund (locked - spent) is never inflated.
|
||||
// Two fractional fills (price 1.5 * size 3 = 4.5; price 2.5 * size 3 = 7.5) sum
|
||||
// to an aggregate quote notional of exactly 12.0, so spent = ceil(12.0) = 12
|
||||
// (NOT the old per-fill floor sum 4+7=11, which understated spend and over-
|
||||
// refunded by 1). Proceeds base rounds DOWN: floor(sum(size)) = floor(6.0) = 6.
|
||||
// The taker locked 1000 quote; refund = 1000-12 = 988, and value_out =
|
||||
// base-proceeds(6) + quote-refund(988) = 994.
|
||||
// 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: 1.5, Size: 3}, {Price: 2.5, Size: 3}} // BUY
|
||||
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 = aggregate notional, rounded UP (the exact arithmetic
|
||||
// settleFromFills uses via quantToCharge). Mirrors fillNotional.
|
||||
// SPENT quote = Σ floor(size×price/PriceScale), the venue's own per-fill quote.
|
||||
spent := fillNotional(fills, takerSide)
|
||||
// PROCEEDS base = aggregate size, rounded DOWN (quantToCredit).
|
||||
var baseAgg float64
|
||||
// PROCEEDS base = Σ size (whole atomic base units).
|
||||
var proceedsBase uint64
|
||||
for _, f := range fills {
|
||||
baseAgg += f.Size
|
||||
proceedsBase += f.Size
|
||||
}
|
||||
proceedsBase := uint64(math.Floor(baseAgg))
|
||||
|
||||
wantRefund := lockedQuote - spent
|
||||
wantOut := proceedsBase + wantRefund
|
||||
|
||||
t.Logf("CONSERVATION LEDGER (rounding): value_in=%d spent(ceil)=%d proceeds_base(floor)=%d refund=%d measured_value_out=%d want=%d",
|
||||
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 != 12 || proceedsBase != 6 {
|
||||
t.Fatalf("arithmetic anchor drifted: spent=%d (want 12) proceeds_base=%d (want 6)", spent, proceedsBase)
|
||||
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. "+
|
||||
|
||||
@@ -17,7 +17,6 @@ package dexvm
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -66,20 +65,29 @@ func (f *fakeMatcherConn) Call(_ context.Context, method string, payload []byte)
|
||||
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]).
|
||||
// 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], math.Float64bits(fl.Price))
|
||||
binary.BigEndian.PutUint64(resp[off+8:off+16], math.Float64bits(fl.Size))
|
||||
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.
|
||||
@@ -208,13 +216,14 @@ func mustParseImport(t *testing.T, b []byte) *txs.ImportTx {
|
||||
}
|
||||
|
||||
// clobSubmitPayload builds the FROZEN 66-byte clob_submit request frame:
|
||||
// poolId[32] | side[1] | isMarket[1] | limit[8] | size[8] | user[16].
|
||||
// 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
|
||||
putZAPFloat(p[42:50], float64(size))
|
||||
binary.BigEndian.PutUint64(p[42:50], size)
|
||||
return p
|
||||
}
|
||||
|
||||
@@ -266,7 +275,7 @@ func indexedByTaker(t *testing.T, h *conservationHarness) [][]byte {
|
||||
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: 1, Size: 1000, Side: 0}}
|
||||
fills := []Fill{{Price: fp(1), Size: 1000, Side: 0}}
|
||||
h := newConservationHarness(t, fills)
|
||||
|
||||
taker := ids.GenerateTestShortID()
|
||||
|
||||
+15
-14
@@ -99,21 +99,22 @@ 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 (LUX quote per native base); it
|
||||
// is a consensus-agreed receipt, so every validator computes the identical LUX
|
||||
// amount from identical inputs — deterministic settlement.
|
||||
// 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 rounds DOWN via
|
||||
// quantToCredit — the SAME asymmetric proceeds rounding settleFromFills uses for
|
||||
// every credited leg (never credit a LUX unit the fill did not realize; the
|
||||
// proxy never mints LUX). Rounding DOWN is also the floor-SAFE direction: a
|
||||
// caller's floor check can never be passed by rounding error, only failed by it.
|
||||
// A non-finite or non-positive price is a malformed fill and is refused.
|
||||
func swapNativeFeeToLUX(nativeFee uint64, luxPerNative float64) (uint64, error) {
|
||||
if !isFinitePositive(luxPerNative) {
|
||||
return 0, fmt.Errorf("dexvm: fee swap: invalid LUX/native price %v", luxPerNative)
|
||||
// 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 quantToCredit(float64(nativeFee) * luxPerNative)
|
||||
return settleUint64(quoteUnits(nativeFee, luxPerNative), "fee-swap LUX")
|
||||
}
|
||||
|
||||
// settleFeeInLUX sources the D-Chain tx fee in canonical LUX from a fee tendered
|
||||
@@ -129,7 +130,7 @@ func swapNativeFeeToLUX(nativeFee uint64, luxPerNative float64) (uint64, error)
|
||||
// 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 float64) (uint64, error) {
|
||||
func (cvm *ChainVM) settleFeeInLUX(nativeFee uint64, luxPerNative uint64) (uint64, error) {
|
||||
if cvm.feePolicy == nil {
|
||||
return 0, fmt.Errorf("dexvm: fee policy not initialized")
|
||||
}
|
||||
|
||||
+15
-18
@@ -7,7 +7,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/chains/dexvm/txs"
|
||||
@@ -141,13 +140,13 @@ 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, 1.0); err != nil || got != fee.MinTxFeeFloor {
|
||||
t.Fatalf("swapNativeFeeToLUX(floor, 1.0) = (%d, %v), want (%d, nil)", got, err, fee.MinTxFeeFloor)
|
||||
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)
|
||||
const price = 0.1 // LUX per native unit
|
||||
price := fp(0.1) // LUX per native unit, fixed-point ×PriceScale
|
||||
first, err := swapNativeFeeToLUX(nativeFee, price)
|
||||
if err != nil {
|
||||
t.Fatalf("swap: %v", err)
|
||||
@@ -158,31 +157,29 @@ func TestSwapNativeFeeToLUX_Deterministic(t *testing.T) {
|
||||
t.Fatalf("non-deterministic swap on iter %d: got (%d,%v), want (%d,nil)", i, got, err, first)
|
||||
}
|
||||
}
|
||||
if first != 2_000_000 { // 20_000_000 * 0.1 = 2_000_000 LUX
|
||||
t.Fatalf("swap(20e6, 0.1) = %d, want 2_000_000", 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, 0.1) = (%d, %v), want (2_000_000, nil)", got, err)
|
||||
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):
|
||||
// 1_000_000 native * 0.1 = 100_000 LUX < 1_000_000 floor.
|
||||
if _, err := cvm.settleFeeInLUX(1_000_000, 0.1); !errors.Is(err, fee.ErrInsufficientFee) {
|
||||
// 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 round DOWN (never credit LUX the fill did not realize, floor-safe):
|
||||
// 3 native * 0.5 = 1.5 LUX -> 1 LUX.
|
||||
if got, err := swapNativeFeeToLUX(3, 0.5); err != nil || got != 1 {
|
||||
t.Fatalf("swapNativeFeeToLUX(3, 0.5) = (%d, %v), want (1, nil) [floor-safe round down]", got, 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)
|
||||
}
|
||||
|
||||
// Malformed fill prices are refused (no silent zero/garbage settlement).
|
||||
for _, bad := range []float64{0, -1, math.NaN(), math.Inf(1), math.Inf(-1)} {
|
||||
if _, err := swapNativeFeeToLUX(fee.MinTxFeeFloor, bad); err == nil {
|
||||
t.Fatalf("swapNativeFeeToLUX with invalid price %v = nil err, want refusal", bad)
|
||||
}
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+114
-144
@@ -18,7 +18,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"math"
|
||||
"math/big"
|
||||
"sort"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -172,7 +172,7 @@ func makeBlock(cvm *ChainVM, parentID ids.ID, height uint64, ts time.Time, txByt
|
||||
// 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: 1, Size: 1000, Side: 0}} // taker BUY, receives 1000 base
|
||||
fills := []Fill{{Price: fp(1), Size: 1000, Side: 0}} // taker BUY, receives 1000 base
|
||||
cvm, matcher, cChainSM, proxyChain, _ := newCountingHarness(t, fills)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -294,24 +294,22 @@ func TestRED_ReVerify_DoublesPlaceOrder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRED_FractionalFill_NeverMints is the fractional-fill conservation proof. A
|
||||
// CLOB fill crosses the wire as float64, but on-chain value is integer asset
|
||||
// units, so a sub-integer proceeds amount cannot be credited without minting a
|
||||
// fractional unit. The CONSERVATION-SAFE rule (the law: the proxy NEVER mints)
|
||||
// is to round a RECEIVED quantity DOWN (quantToCredit) and a SPENT quantity UP
|
||||
// (quantToCharge). Worst case the taker is under-credited by <1 unit; that
|
||||
// sub-unit is conserved on the d-chain side (the maker's leg), never fabricated
|
||||
// out of the proxy — the opposite of a mint.
|
||||
// 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 resolved behavior with an ESCROW present so both legs are
|
||||
// auditable: a taker BUY locks 10 quote and fills 4.5 base @ price 2 (a fill
|
||||
// stream with a fractional base AND a fractional notional). The quote spent
|
||||
// ceils to 9, base proceeds floor to 4; value_out (4 base + 1 quote refund) must
|
||||
// never exceed value_in, and the locked-quote ledger must balance exactly.
|
||||
// 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 = sum(size) = 4.5 -> floor 4. spends quote =
|
||||
// sum(price*size) = 9.0 -> ceil 9. locked 10 quote -> refund 10-9 = 1.
|
||||
fills := []Fill{{Price: 2, Size: 4.5, Side: 0}}
|
||||
// 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()
|
||||
@@ -346,57 +344,41 @@ func TestRED_FractionalFill_NeverMints(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Logf("fractional fill: base proceeds=%d (floor 4.5) quote refund=%d (locked 10 - ceil(9.0))", base, refund)
|
||||
t.Logf("fractional-quote fill: base proceeds=%d (Σ size = 3) quote refund=%d (locked 10 - floor(7.5)=7)", base, refund)
|
||||
|
||||
// Proceeds round DOWN: never credit more base than the integer floor.
|
||||
if base > 4 {
|
||||
t.Fatalf("MINT: credited %d base for a 4.5 fill — proceeds must floor to 4, never round up.", base)
|
||||
// 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 ceils to 9, so refund is exactly 1 — never inflated (that would
|
||||
// be the escrow-truncation mint), never negative.
|
||||
if refund != lockedQuote-9 {
|
||||
t.Fatalf("CONSERVATION VIOLATED: quote refund=%d, want %d (locked 10 - spent ceil(9.0)=9).", refund, lockedQuote-9)
|
||||
// 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(9) + refund(1) == locked(10).
|
||||
if spent := lockedQuote - refund; spent != 9 {
|
||||
// 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 float64->uint64 SATURATION proof —
|
||||
// the headline of the finding: a fill amount that exceeds what a uint64 can hold
|
||||
// must be REFUSED at the asset boundary, never silently truncated/saturated into
|
||||
// a valid-looking on-chain amount.
|
||||
// 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.
|
||||
//
|
||||
// Go's float64->uint64 conversion does NOT wrap or error on overflow: uint64(19e18)
|
||||
// SATURATES to 18446744073709551615 (uint64 max). uint64 max is ~1.845e19 = only
|
||||
// ~18.44 tokens at 18 decimals, so a single 19-token fill, cast naively, would be
|
||||
// summed into the exported amount as a plausible ~18.44-token value — value minted
|
||||
// out of thin air, and (because the export amount is a fixed 8-byte uint64 with no
|
||||
// widening downstream — atomic.go encodeExportedOutput PutUint64(v[53:61])) there
|
||||
// is nothing downstream to catch it.
|
||||
// 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.
|
||||
//
|
||||
// The closed behavior: settlement aggregates fills as exact float64 ONCE, then
|
||||
// quantToCredit/quantToCharge reject any aggregate > maxSettlementUnit (the largest
|
||||
// float64 strictly below 2^64) BEFORE the uint64() cast. So an oversized fill makes
|
||||
// settleFromFills ERROR and export NOTHING — refusal, never a saturated mint.
|
||||
//
|
||||
// Two legs are exercised, since the finding's PoC inflates BOTH the proceeds cast
|
||||
// (uint64(size)) and the spent/notional cast (uint64(price*size)):
|
||||
// - PROCEEDS overflow: a taker BUY whose base size sums to 19e18 (> uint64 max).
|
||||
// - NOTIONAL overflow: a taker BUY whose quote notional (price*size) overflows,
|
||||
// which would understate spent => inflate the escrow refund (the mint vector).
|
||||
// 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) {
|
||||
// Ground truth the guard must beat: a naive cast saturates instead of erroring.
|
||||
// overSize is a runtime float64 (NOT an untyped constant) so the uint64() cast
|
||||
// is the runtime saturating conversion under test — a constant conversion of an
|
||||
// out-of-range value is a compile error, which is not the behavior being probed.
|
||||
var overSize float64 = 19e18 // 19 tokens @ 18 decimals; > uint64 max (~1.845e19)
|
||||
if uint64(overSize) != ^uint64(0) {
|
||||
t.Fatalf("precondition: expected uint64(%v) to saturate to uint64 max, got %d",
|
||||
overSize, uint64(overSize))
|
||||
}
|
||||
|
||||
auditCredited := func(ar *atomicRequests) uint64 {
|
||||
var total uint64
|
||||
for _, reqs := range ar.reqs {
|
||||
@@ -409,36 +391,39 @@ func TestRED_OverflowFill_SaturatesUint64(t *testing.T) {
|
||||
return total
|
||||
}
|
||||
|
||||
// --- Leg 1: PROCEEDS overflow (uint64(size) saturation) ---------------------
|
||||
// taker BUY receives base = sum(size) = 19e18 -> overflows; settle must refuse.
|
||||
// --- 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: 1, Size: overSize, Side: 0}}
|
||||
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 cast (refund leg absent).
|
||||
// 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 (naive cast would saturate to %d)", err, credited, ^uint64(0))
|
||||
t.Logf("proceeds overflow: settle err=%v exported=%d (Σ size = 2^64+1 must not wrap)", err, credited)
|
||||
|
||||
if err == nil {
|
||||
t.Fatalf("SATURATION MINT: settle accepted a 19e18 base fill (> uint64 max); it must be "+
|
||||
"refused at the asset boundary, not cast (which saturates to %d).", ^uint64(0))
|
||||
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("SATURATION MINT: refused settle still exported %d — refusal must export nothing.", credited)
|
||||
t.Fatalf("OVERFLOW MINT: refused settle still exported %d — refusal must export nothing.", credited)
|
||||
}
|
||||
})
|
||||
|
||||
// --- Leg 2: NOTIONAL overflow (uint64(price*size) saturation) ---------------
|
||||
// taker BUY spends quote = sum(price*size); make the NOTIONAL overflow while
|
||||
// the base size stays in range, so the only overflow is the spent/refund leg.
|
||||
// A saturated-but-floored spent would UNDERstate spend => inflate refund =
|
||||
// locked - spent => quote minted out of the proxy's own escrow.
|
||||
// --- 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 1e10 (in range), price 1e10 -> notional 1e20 > uint64 max.
|
||||
fills := []Fill{{Price: 1e10, Size: 1e10, Side: 0}}
|
||||
// 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()
|
||||
@@ -455,11 +440,11 @@ func TestRED_OverflowFill_SaturatesUint64(t *testing.T) {
|
||||
t.Logf("notional overflow: settle err=%v exported=%d (a saturated/floored spent would inflate the refund)", err, credited)
|
||||
|
||||
if err == nil {
|
||||
t.Fatalf("SATURATION MINT: settle accepted a 1e20 quote notional (> uint64 max); the spent " +
|
||||
"cast would saturate and inflate refund = locked - spent => minted quote.")
|
||||
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("SATURATION MINT: refused settle still exported %d — refusal must export nothing.", credited)
|
||||
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).
|
||||
@@ -468,58 +453,40 @@ func TestRED_OverflowFill_SaturatesUint64(t *testing.T) {
|
||||
t.Fatalf("escrow lookup: %v", eerr)
|
||||
}
|
||||
if !haveEscrow {
|
||||
t.Fatalf("SATURATION MINT: refused settle consumed the escrow — a refused settle must leave it intact.")
|
||||
t.Fatalf("OVERFLOW MINT: refused settle consumed the escrow — a refused settle must leave it intact.")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestRED_SettlementBoundary_ExactUint64Edge pins the float->uint64 boundary
|
||||
// CONSTANT (maxSettlementUnit) exactly, so a future edit cannot widen it past the
|
||||
// representable range and silently reintroduce the saturation mint. maxSettlementUnit
|
||||
// is the largest float64 strictly below 2^64 (== 2^64 - 2048; 2^64 itself is the
|
||||
// next representable float64 and is ONE above uint64 max):
|
||||
// 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 (maxSettlementUnit) converts IN-RANGE for both
|
||||
// a credit (floor) and a charge (ceil);
|
||||
// - the next float64 up (2^64) is refused by both — it is exactly one above
|
||||
// uint64 max, the value uint64(.) would wrap/saturate on.
|
||||
// - 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)
|
||||
|
||||
// maxSettlementUnit must be strictly below 2^64 and cast in-range.
|
||||
if maxSettlementUnit >= float64(u64max) {
|
||||
// (float64(u64max) rounds to 2^64; the constant must be below it.)
|
||||
t.Fatalf("maxSettlementUnit %.0f must be strictly below 2^64", maxSettlementUnit)
|
||||
}
|
||||
if got, err := quantToCredit(maxSettlementUnit); err != nil {
|
||||
t.Fatalf("quantToCredit(maxSettlementUnit) must pass, got err=%v", err)
|
||||
} else if uint64(got) > u64max { // tautology on uint64, but documents in-range intent
|
||||
t.Fatalf("quantToCredit(maxSettlementUnit)=%d out of uint64 range", got)
|
||||
}
|
||||
if _, err := quantToCharge(maxSettlementUnit); err != nil {
|
||||
t.Fatalf("quantToCharge(maxSettlementUnit) must pass, got err=%v", err)
|
||||
// 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)
|
||||
}
|
||||
|
||||
// The next representable float64 above the boundary is exactly 2^64 — one above
|
||||
// uint64 max — and MUST be refused by both directions.
|
||||
over := math.Nextafter(maxSettlementUnit, math.Inf(1))
|
||||
if over != float64(u64max)+1 && over != 18446744073709551616.0 {
|
||||
t.Logf("note: nextafter(maxSettlementUnit)=%.0f (expected 2^64=18446744073709551616)", over)
|
||||
}
|
||||
if _, err := quantToCredit(over); err == nil {
|
||||
t.Fatalf("OVERFLOW: quantToCredit(%.0f) accepted a value above uint64 max — must refuse.", over)
|
||||
}
|
||||
if _, err := quantToCharge(over); err == nil {
|
||||
t.Fatalf("OVERFLOW: quantToCharge(%.0f) accepted a value above uint64 max — must refuse.", over)
|
||||
// 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.")
|
||||
}
|
||||
|
||||
// +Inf (e.g. a per-fill product that overflowed to +Inf in the aggregate loop)
|
||||
// is refused too — the aggregate never reaches the cast.
|
||||
if _, err := quantToCredit(math.Inf(1)); err == nil {
|
||||
t.Fatalf("OVERFLOW: quantToCredit(+Inf) accepted — must refuse.")
|
||||
}
|
||||
if _, err := quantToCharge(math.Inf(1)); err == nil {
|
||||
t.Fatalf("OVERFLOW: quantToCharge(+Inf) accepted — 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.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -538,8 +505,8 @@ 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: 1, Size: 10, Side: 0},
|
||||
{Price: 1, Size: 1000, Side: 1},
|
||||
{Price: fp(1), Size: 10, Side: 0},
|
||||
{Price: fp(1), Size: 1000, Side: 1},
|
||||
}
|
||||
cvm, _, _, _, _ := newCountingHarness(t, fills)
|
||||
|
||||
@@ -565,7 +532,6 @@ func TestRED_MixedSideFills_OverCredits(t *testing.T) {
|
||||
if credited != 0 {
|
||||
t.Fatalf("OVER-CREDIT/MINT: refused settle still exported %d base — refusal must accumulate nothing.", credited)
|
||||
}
|
||||
_ = math.Float64bits
|
||||
}
|
||||
|
||||
// TestRED_DecodeFills_RejectsInvalidSideByte is the WIRE-BOUNDARY proof for the
|
||||
@@ -582,8 +548,8 @@ 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: 1, Size: 10, Side: 0},
|
||||
{Price: 1, Size: 5, Side: 0},
|
||||
{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
|
||||
@@ -611,7 +577,7 @@ func TestRED_DecodeFills_RejectsInvalidSideByte(t *testing.T) {
|
||||
// (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: 1, Size: 1000, Side: 0}}
|
||||
fills := []Fill{{Price: fp(1), Size: 1000, Side: 0}}
|
||||
cvm, matcher, cChainSM, proxyChain, _ := newCountingHarness(t, fills)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -659,22 +625,25 @@ func TestRED_CrashBeforeAccept_DoubleSubmits(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRED_EscrowTruncation_OverRefunds is the value-EXTRACTION proof. The settle
|
||||
// computes spent quote as sum(uint64(price*size)) — truncating each fill's notional
|
||||
// DOWN. For a taker BUY, refund = locked - spent. Understated spent => INFLATED
|
||||
// refund. The taker receives the (real) base proceeds AND a refund larger than
|
||||
// their true unspent collateral: net value extracted from the proxy escrow.
|
||||
// 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 fills priced 0.99 each, so the
|
||||
// TRUE quote cost is 99 (taker should get ~1 quote refund + 100 base). But each
|
||||
// uint64(0.99*1)=0 => recorded spent=0 => refund=100 quote, PLUS 100 base. The
|
||||
// taker walks away with 100 quote + 100 base from a 100-quote lock that bought
|
||||
// 100 base. ~99 quote minted.
|
||||
// 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 1 @ price 0.99 (taker BUY). True spent ~= 99 quote.
|
||||
// 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: 0.99, Size: 1, Side: 0}
|
||||
fills[i] = Fill{Price: fp(0.25), Size: 4, Side: 0}
|
||||
}
|
||||
cvm, _, _, _, _ := newCountingHarness(t, fills)
|
||||
|
||||
@@ -694,14 +663,15 @@ func TestRED_EscrowTruncation_OverRefunds(t *testing.T) {
|
||||
}
|
||||
|
||||
// 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) < 60 {
|
||||
if len(e.Value) != exportedOutputSize {
|
||||
continue
|
||||
}
|
||||
var a ids.ID
|
||||
copy(a[:], e.Value[20:52])
|
||||
copy(a[:], e.Value[21:53])
|
||||
amt := binary.BigEndian.Uint64(e.Value[53:61])
|
||||
if a == lockedAsset {
|
||||
refund += amt
|
||||
@@ -710,8 +680,8 @@ func TestRED_EscrowTruncation_OverRefunds(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
trueBase := uint64(100) // sum(size)
|
||||
trueSpent := uint64(99) // 100 * 0.99
|
||||
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)
|
||||
@@ -720,9 +690,9 @@ func TestRED_EscrowTruncation_OverRefunds(t *testing.T) {
|
||||
// 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 quote truncated via uint64(price*size)). The taker "+
|
||||
"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, base, refund-trueRefund)
|
||||
refund, trueRefund, trueSpent, base, refund-trueRefund)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -762,8 +732,8 @@ func exportKeys(ar *atomicRequests) [][]byte {
|
||||
// deterministic-seed fix.
|
||||
func TestRED_SettlementExportKeyDeterminism(t *testing.T) {
|
||||
fills := []Fill{
|
||||
{Price: 2, Size: 50, Side: 0}, // taker BUY: receives base, pays quote
|
||||
{Price: 2, Size: 50, Side: 0},
|
||||
{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))
|
||||
@@ -829,7 +799,7 @@ func TestRED_SettlementExportKeyDeterminism(t *testing.T) {
|
||||
// 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: 1, Size: 1000, Side: 0}}
|
||||
fills := []Fill{{Price: fp(1), Size: 1000, Side: 0}}
|
||||
blockHash := deriveBlockHash(42, time.Unix(123, 0))
|
||||
const txIndex = uint32(0)
|
||||
taker := ids.GenerateTestShortID()
|
||||
|
||||
@@ -35,7 +35,7 @@ func TestRED_FillAttestation_VerifyRejectsForgedFills(t *testing.T) {
|
||||
t.Fatalf("keygen: %v", err)
|
||||
}
|
||||
blockHash := ids.GenerateTestID()
|
||||
entries := []carriedFill{{txIndex: 0, fills: []Fill{{Price: 2, Size: 100, Side: 0}}}}
|
||||
entries := []carriedFill{{txIndex: 0, fills: []Fill{{Price: fp(2), Size: 100, Side: 0}}}}
|
||||
|
||||
sig := signFillAttestation(priv, blockHash, entries)
|
||||
if len(sig) == 0 {
|
||||
@@ -60,7 +60,7 @@ func TestRED_FillAttestation_VerifyRejectsForgedFills(t *testing.T) {
|
||||
|
||||
// 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: 2, Size: 1_000_000, Side: 0}}}}
|
||||
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)
|
||||
}
|
||||
@@ -99,7 +99,7 @@ func TestRED_FillAttestation_EndToEnd_ValidAttestationSettles(t *testing.T) {
|
||||
}
|
||||
pubFromSeed := priv.Public().(ed25519.PublicKey)
|
||||
|
||||
fills := []Fill{{Price: 2, Size: 150, Side: 0}}
|
||||
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).
|
||||
@@ -154,7 +154,7 @@ func TestRED_FillAttestation_FabricatedFillsRefunded(t *testing.T) {
|
||||
}
|
||||
|
||||
// A proposer carries a FABRICATED fill (200 base) but cannot attest it (no seed).
|
||||
fills := []Fill{{Price: 2, Size: 200, Side: 0}}
|
||||
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.
|
||||
@@ -205,7 +205,7 @@ func TestRED_FillAttestation_FabricatedFillsRefunded(t *testing.T) {
|
||||
// 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: 2, Size: 100, Side: 0}}
|
||||
fills := []Fill{{Price: fp(2), Size: 100, Side: 0}}
|
||||
cvm, _, cChainSM, proxyChain, _ := newCountingHarness(t, fills)
|
||||
// Default config: no FillAttestationPubKey => enforcement off.
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -135,7 +135,7 @@ func TestRED_GenesisAttestation_PubKeyReachesConfig(t *testing.T) {
|
||||
}
|
||||
// And the gate is now LIVE: verify rejects a missing signature (enforcement on).
|
||||
bh := ids.GenerateTestID()
|
||||
entries := []carriedFill{{txIndex: 0, fills: []Fill{{Price: 2, Size: 100, Side: 0}}}}
|
||||
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)
|
||||
}
|
||||
@@ -154,7 +154,7 @@ func TestRED_GenesisAttestation_FabricatedBlockFullRefund(t *testing.T) {
|
||||
}
|
||||
// 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: 2, Size: 200, Side: 0}}
|
||||
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)
|
||||
|
||||
@@ -156,8 +156,8 @@ func TestRED_PerValidatorRelay_SplitsConsensus(t *testing.T) {
|
||||
|
||||
// The book moves: first relay fills 1000 base @1; a later relay fills only 600.
|
||||
matcher := &movingBookMatcher{perCall: [][]Fill{
|
||||
{{Price: 1, Size: 1000, Side: 0}},
|
||||
{{Price: 1, Size: 600, Side: 0}},
|
||||
{{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
|
||||
@@ -223,7 +223,7 @@ func TestRED_PerValidatorRelay_SplitsConsensus(t *testing.T) {
|
||||
// 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: 1, Size: 1000, Side: 0}}}}
|
||||
matcher := &movingBookMatcher{perCall: [][]Fill{{{Price: fp(1), Size: 1000, Side: 0}}}}
|
||||
|
||||
taker := fixedShortID("verify-purity-taker")
|
||||
asset := idFromByte(0xb2)
|
||||
|
||||
@@ -124,8 +124,8 @@ func TestRED_StateRoot_DivergentRelayOutcomeForksRoot(t *testing.T) {
|
||||
|
||||
// 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: 1, Size: 1000, Side: 0}})
|
||||
resB := runBlock([]Fill{{Price: 1, Size: 600, Side: 0}})
|
||||
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.
|
||||
|
||||
@@ -26,7 +26,6 @@ package dexvm
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -70,7 +69,7 @@ func legsTo(ar *atomicRequests, owner ids.ShortID) map[ids.ID]uint64 {
|
||||
// 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: 2, Size: 100, Side: 0}}
|
||||
fills := []Fill{{Price: fp(2), Size: 100, Side: 0}}
|
||||
cvm, _, _, _, _ := newCountingHarness(t, fills)
|
||||
|
||||
taker := ids.GenerateTestShortID()
|
||||
@@ -127,7 +126,7 @@ func TestRED_SwapProceeds_ExportedUnderRealOutputAsset(t *testing.T) {
|
||||
// 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: 2, Size: 150, Side: 0}}
|
||||
fills := []Fill{{Price: fp(2), Size: 150, Side: 0}}
|
||||
cvm, matcher, cChainSM, proxyChain, _ := newCountingHarness(t, fills)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -217,9 +216,10 @@ type atomicShared interface {
|
||||
|
||||
var _ = txs.RailSwap
|
||||
|
||||
// floatBits packs a float64 price into the uint64 wire form the relay carries (the
|
||||
// same math.Float64bits the precompile's priceLimitToCLOB produces).
|
||||
func floatBits(p float64) uint64 { return math.Float64bits(p) }
|
||||
// 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
|
||||
@@ -235,14 +235,14 @@ func TestRED_SwapSlippageLimit_RejectsWorseThanLimit(t *testing.T) {
|
||||
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: 3, Size: 100, Side: 0}}
|
||||
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, floatBits(limit), true, ids.GenerateTestID(), 0, ar)
|
||||
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.")
|
||||
@@ -257,14 +257,14 @@ func TestRED_SwapSlippageLimit_RejectsWorseThanLimit(t *testing.T) {
|
||||
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: 2, Size: 100, Side: 0}}
|
||||
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, floatBits(limit), true, ids.GenerateTestID(), 0, ar2); err != nil {
|
||||
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 {
|
||||
@@ -274,14 +274,14 @@ func TestRED_SwapSlippageLimit_RejectsWorseThanLimit(t *testing.T) {
|
||||
|
||||
// (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: 4, Size: 100, Side: 1}}
|
||||
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, floatBits(5.0), false, ids.GenerateTestID(), 0, ar3); err == nil {
|
||||
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")
|
||||
|
||||
+32
-20
@@ -81,9 +81,17 @@ const (
|
||||
)
|
||||
|
||||
// FillWireSize is one fill in a clob_submit response: price[8]+size[8]+side[1].
|
||||
// FROZEN — must equal dex/pkg/api.FillWireSize and the precompile's fillWireSize.
|
||||
// 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.
|
||||
@@ -187,24 +195,28 @@ func (r *RelayClient) Close() error {
|
||||
}
|
||||
|
||||
// Fill is one execution returned by the d-chain matcher: price + size + the
|
||||
// taker side this submit took with. The proxy derives the settlement delta
|
||||
// ONLY from these server-returned fills — never from a client-supplied amount.
|
||||
// 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 float64
|
||||
Size float64
|
||||
Price uint64
|
||||
Size uint64
|
||||
Side uint8
|
||||
}
|
||||
|
||||
// DecodeFills parses a clob_submit response: count[4] then count×(price[8] +
|
||||
// size[8] + side[1]). Every 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 finite and strictly positive, and side MUST be a valid CLOB
|
||||
// side (0=BUY, 1=SELL). A side byte outside {0,1} is malformed wire exactly like
|
||||
// a NaN 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.)
|
||||
// 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))
|
||||
@@ -216,15 +228,15 @@ func DecodeFills(data []byte) ([]Fill, error) {
|
||||
fills := make([]Fill, 0, n)
|
||||
off := 4
|
||||
for i := 0; i < n; i++ {
|
||||
p := float64FromBits(data[off : off+8])
|
||||
s := float64FromBits(data[off+8 : off+16])
|
||||
p := binary.BigEndian.Uint64(data[off : off+8])
|
||||
s := binary.BigEndian.Uint64(data[off+8 : off+16])
|
||||
side := data[off+16]
|
||||
off += FillWireSize
|
||||
if !isFinitePositive(p) {
|
||||
return nil, fmt.Errorf("fill %d: invalid price %v", i, p)
|
||||
if p == 0 {
|
||||
return nil, fmt.Errorf("fill %d: invalid price %d", i, p)
|
||||
}
|
||||
if !isFinitePositive(s) {
|
||||
return nil, fmt.Errorf("fill %d: invalid size %v", i, s)
|
||||
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)
|
||||
|
||||
+3
-2
@@ -356,8 +356,9 @@ type RelayOrderTx struct {
|
||||
// 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, float64-bits) for a
|
||||
// settling clob_submit, derived from the V4 SqrtPriceLimitX96. settleFromFills refuses
|
||||
// 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
|
||||
|
||||
+74
-80
@@ -11,7 +11,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -939,11 +939,11 @@ func (vm *VM) settleCarried(result *BlockResult, ar *atomicRequests) {
|
||||
// RECEIVES the opposite asset. The settle therefore exports TWO legs:
|
||||
//
|
||||
// PROCEEDS leg — the received (opposite) asset:
|
||||
// - taker BUY (side 0): receives base = sum(size).
|
||||
// - taker SELL (side 1): receives quote = sum(price*size).
|
||||
// - taker BUY (side 0): receives base = Σ size.
|
||||
// - taker SELL (side 1): receives quote = Σ floor(size × price / PriceScale).
|
||||
// REFUND leg — the unfilled remainder of the LOCKED asset:
|
||||
// - taker BUY locked quote; spent = sum(price*size); refund = locked - spent.
|
||||
// - taker SELL locked base; spent = sum(size); refund = locked - spent.
|
||||
// - taker BUY locked quote; spent = Σ floor(size×price/PriceScale); refund = locked - spent.
|
||||
// - taker SELL locked base; spent = Σ size; refund = locked - spent.
|
||||
//
|
||||
// Crediting only the proceeds leg (the prior behavior) DESTROYED the unfilled
|
||||
// remainder on every partial/zero fill. The refund leg closes that leak: the
|
||||
@@ -953,25 +953,18 @@ func (vm *VM) settleCarried(result *BlockResult, ar *atomicRequests) {
|
||||
// refunded twice. With no escrow (a relay not backed by a proxy-side import),
|
||||
// there is no locked collateral to refund — proceeds only.
|
||||
//
|
||||
// FRACTIONAL-NOTIONAL ROUNDING (RED escrow-truncation mint): fills cross the ZAP
|
||||
// wire as float64, so base = sum(size) and quote = sum(price*size) are generally
|
||||
// FRACTIONAL while on-chain value moves in integer asset units. The float->uint
|
||||
// conversion is ASYMMETRIC by purpose, to preserve "the proxy never mints"
|
||||
// (atomic.go quantToCredit/quantToCharge):
|
||||
// - PROCEEDS (the opposite asset the taker RECEIVES) round DOWN — never credit
|
||||
// a unit that was not realized.
|
||||
// - SPENT (the locked asset consumed, which REDUCES the refund) round UP —
|
||||
// never UNDERstate spend. Understating spend is exactly this bug: a BUY's
|
||||
// spent = floor(notional) inflates refund = locked - spent, so the taker
|
||||
// keeps the base proceeds AND a refund larger than the true unspent quote =>
|
||||
// quote minted out of escrow.
|
||||
//
|
||||
// Totals are summed over the fills ONCE as exact floats, then rounded ONCE at the
|
||||
// asset boundary. The prior per-fill uint64(price*size) truncated DOWN on every
|
||||
// fill and summed the error: 100 fills of notional 0.99 recorded spent=0 and
|
||||
// refunded the entire lock. Aggregate ceiling gives spent=ceil(99.0)=99, so the
|
||||
// refund is capped at the true unspent value — no extraction. spent > locked is
|
||||
// still refused (minting against the proxy's own escrow).
|
||||
// EXACT-INTEGER NOTIONAL (no float, no mint): fills cross the ZAP wire as EXACT
|
||||
// integers — size is atomic base units, price is fixed-point ×PriceScale — so
|
||||
// base = Σ size and quote = Σ floor(size × price / PriceScale) are themselves exact
|
||||
// integers, computed with the SAME per-fill floor the venue matcher applied. The
|
||||
// proxy therefore moves EXACTLY what the venue matched: there is no fractional
|
||||
// notional to round, no asymmetric floor/ceil, and — decisively — no value-bearing
|
||||
// float→int conversion (whose out-of-range result is architecture-dependent and
|
||||
// was the cross-arch fork). The escrow-truncation mint is structurally impossible:
|
||||
// spent is the venue's own quote integer, refund = locked - spent, and spent >
|
||||
// locked is still refused (minting against the proxy's own escrow). The aggregates
|
||||
// are summed in big.Int and refused if they exceed uint64 — a value the proxy
|
||||
// cannot represent exactly is never settled.
|
||||
//
|
||||
// DETERMINISM (RED split): the export this settle constructs is reconstructed
|
||||
// independently on every validator, so its shared-memory UTXO keys MUST be a
|
||||
@@ -1030,68 +1023,69 @@ func (vm *VM) settleFromFills(relaySender ids.ShortID, collateralRef ids.ID, fil
|
||||
return fmt.Errorf("settle: invalid taker side %d", takerSide)
|
||||
}
|
||||
|
||||
// Aggregate the fill totals ONCE as exact floats (base = sum(size), quote =
|
||||
// sum(price*size)). Rounding to integer asset units happens later, in the
|
||||
// direction proper to each leg — NOT per-fill, which would truncate DOWN on
|
||||
// every fill and sum a directional leak (the escrow-truncation mint).
|
||||
// Aggregate the fill totals as EXACT integers: base = Σ size (atomic base
|
||||
// units), quote = Σ floor(size × price / PriceScale) — the SAME per-fill floor
|
||||
// the venue's matcher (quoteUnitsFor) and the precompile's fillsToDelta apply,
|
||||
// so the proxy settles EXACTLY the value the venue moved. There is no fractional
|
||||
// notional and no float→int conversion on this path (the cross-arch fork the
|
||||
// exact-integer wire eliminates); big.Int accumulation cannot overflow and the
|
||||
// narrowing to uint64 refuses any aggregate the proxy cannot represent exactly.
|
||||
//
|
||||
// SINGLE-SIDE GUARD (RED mixed-side over-credit): the proceeds/spent split
|
||||
// below applies ONE direction (takerSide) to the WHOLE aggregate, so a fill
|
||||
// stream that mixes sides would be credited as if every fill took takerSide —
|
||||
// minting the opposite-side volume (a lying/MITM backend returns [BUY 10,
|
||||
// SELL 1000] and the proxy credits base = 1010). A submit cannot legitimately
|
||||
// fill both sides; refuse the stream rather than over-credit.
|
||||
// SINGLE-SIDE GUARD (RED mixed-side over-credit): the proceeds/spent split below
|
||||
// applies ONE direction (takerSide) to the WHOLE aggregate, so a fill stream that
|
||||
// mixes sides would be credited as if every fill took takerSide — minting the
|
||||
// opposite-side volume (a lying/MITM backend returns [BUY 10, SELL 1000] and the
|
||||
// proxy credits base = 1010). A submit cannot legitimately fill both sides; refuse
|
||||
// the stream rather than over-credit.
|
||||
//
|
||||
// SLIPPAGE-LIMIT GUARD (MEDIUM — bounded sandwich/MEV): the taker carried a
|
||||
// worst-acceptable CLOB price (priceLimit, derived from their V4 SqrtPriceLimitX96).
|
||||
// A fill WORSE than that limit is refused before any value moves — so an adversary
|
||||
// who sandwiches the order and moves the price beyond the taker's floor produces
|
||||
// fills the proxy will not settle; the escrow stays intact and is reclaimable. The
|
||||
// limit composes per-fill (it is a price bound, not a min-total-out), so each fill is
|
||||
// checked independently. limitIsUpper governs the direction: an exact-input BUY caps
|
||||
// the price ABOVE the limit (never pay more than `limit` quote per base); a SELL
|
||||
// floors it BELOW (never receive less than `limit`). priceLimit==0 means no limit
|
||||
// (the V4 sentinel min/max sqrt price, preserved as "unbounded").
|
||||
var limit float64
|
||||
if priceLimit != 0 {
|
||||
limit = math.Float64frombits(priceLimit)
|
||||
}
|
||||
var baseFloat, quoteFloat float64
|
||||
// worst-acceptable CLOB price (priceLimit, a uint64 fixed-point ×PriceScale value
|
||||
// derived from their V4 SqrtPriceLimitX96, the SAME grid as Fill.Price). A fill
|
||||
// WORSE than that limit is refused before any value moves — so an adversary who
|
||||
// sandwiches the order and moves the price beyond the taker's floor produces fills
|
||||
// the proxy will not settle; the escrow stays intact and is reclaimable. The limit
|
||||
// composes per-fill (it is a price bound, not a min-total-out), so each fill is
|
||||
// checked independently by EXACT integer comparison. limitIsUpper governs the
|
||||
// direction: an exact-input BUY caps the price ABOVE the limit (never pay more than
|
||||
// `limit` quote per base); a SELL floors it BELOW (never receive less than `limit`).
|
||||
// priceLimit==0 means no limit (the V4 sentinel min/max sqrt price, "unbounded").
|
||||
base := new(big.Int)
|
||||
quote := new(big.Int)
|
||||
for _, f := range fills {
|
||||
if f.Side != takerSide {
|
||||
return fmt.Errorf("settle: mixed-side fills (fill side %d != taker side %d)", f.Side, takerSide)
|
||||
}
|
||||
if limit > 0 {
|
||||
if limitIsUpper && f.Price > limit {
|
||||
return fmt.Errorf("settle: fill price %g exceeds slippage limit %g (sandwich/MEV guard)", f.Price, limit)
|
||||
if priceLimit != 0 {
|
||||
if limitIsUpper && f.Price > priceLimit {
|
||||
return fmt.Errorf("settle: fill price %d exceeds slippage limit %d (sandwich/MEV guard)", f.Price, priceLimit)
|
||||
}
|
||||
if !limitIsUpper && f.Price < limit {
|
||||
return fmt.Errorf("settle: fill price %g below slippage limit %g (sandwich/MEV guard)", f.Price, limit)
|
||||
if !limitIsUpper && f.Price < priceLimit {
|
||||
return fmt.Errorf("settle: fill price %d below slippage limit %d (sandwich/MEV guard)", f.Price, priceLimit)
|
||||
}
|
||||
}
|
||||
baseFloat += f.Size
|
||||
quoteFloat += f.Price * f.Size
|
||||
base.Add(base, new(big.Int).SetUint64(f.Size))
|
||||
quote.Add(quote, quoteUnits(f.Size, f.Price))
|
||||
}
|
||||
|
||||
// Directional rounding by purpose (atomic.go): a RECEIVED quantity rounds DOWN
|
||||
// (never over-credit); a SPENT quantity rounds UP (never inflate the refund).
|
||||
// BUY receives base, spends quote. SELL receives quote, spends base.
|
||||
baseUnits, err := settleUint64(base, "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
quoteTotal, err := settleUint64(quote, "quote")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// BUY receives base, spends quote. SELL receives quote, spends base. Both legs
|
||||
// are the venue's own exact integers — the proxy moves precisely what the venue
|
||||
// matched (no rounding, no mint); spent > locked is still refused below.
|
||||
var proceeds uint64 // the opposite asset the taker receives
|
||||
var spent uint64 // the locked asset consumed (reduces the refund)
|
||||
if takerSide == 0 { // BUY
|
||||
if proceeds, err = quantToCredit(baseFloat); err != nil {
|
||||
return err
|
||||
}
|
||||
if spent, err = quantToCharge(quoteFloat); err != nil {
|
||||
return err
|
||||
}
|
||||
if takerSide == 0 { // BUY
|
||||
proceeds = baseUnits
|
||||
spent = quoteTotal
|
||||
} else { // SELL
|
||||
if proceeds, err = quantToCredit(quoteFloat); err != nil {
|
||||
return err
|
||||
}
|
||||
if spent, err = quantToCharge(baseFloat); err != nil {
|
||||
return err
|
||||
}
|
||||
proceeds = quoteTotal
|
||||
spent = baseUnits
|
||||
}
|
||||
|
||||
outs := make([]txs.AtomicOutput, 0, 2)
|
||||
@@ -1499,24 +1493,24 @@ func (vm *VM) cChainID() ids.ID {
|
||||
}
|
||||
|
||||
// encodeCLOBPlace builds a byte-identical clob_place frame from a thin place
|
||||
// envelope, mirroring the d-chain gateway's expected layout (poolId[32] |
|
||||
// side[1] | price[8] | size[8] | maker[16]).
|
||||
// envelope, mirroring the d-chain gateway's frozen exact-integer layout
|
||||
// (poolId[32] | side[1] | price[8:u64 fixed ×PriceScale] | size[8:u64 base units] |
|
||||
// maker[16]). tx.Price is a whole quote-per-base price scaled onto the matcher's
|
||||
// PriceInt grid (× PriceScale); tx.Size is already atomic base units. No float
|
||||
// touches the wire — the venue reads these exact integers directly (dex/pkg/zapwire
|
||||
// EncodePlace / DecodePlace), so no architecture-dependent float→int conversion
|
||||
// can fork the derived book state.
|
||||
func encodeCLOBPlace(tx *txs.PlaceOrderTx) []byte {
|
||||
payload := make([]byte, 65)
|
||||
copy(payload[0:32], tx.PoolID[:])
|
||||
payload[32] = tx.Side
|
||||
putZAPFloat(payload[33:41], float64(tx.Price))
|
||||
putZAPFloat(payload[41:49], float64(tx.Size))
|
||||
binary.BigEndian.PutUint64(payload[33:41], tx.Price*PriceScale)
|
||||
binary.BigEndian.PutUint64(payload[41:49], tx.Size)
|
||||
maker := tx.Sender()
|
||||
copy(payload[49:65], maker[:16])
|
||||
return payload
|
||||
}
|
||||
|
||||
// putZAPFloat writes a ZAP float wire field (big-endian IEEE-754 float64 bits).
|
||||
func putZAPFloat(b []byte, f float64) {
|
||||
binary.BigEndian.PutUint64(b, math.Float64bits(f))
|
||||
}
|
||||
|
||||
// idHash is the canonical 32-byte hash used for derived ids.
|
||||
func idHash(b []byte) [32]byte { return sha256.Sum256(b) }
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ require (
|
||||
github.com/luxfi/constants v1.5.8
|
||||
github.com/luxfi/crypto v1.19.26
|
||||
github.com/luxfi/database v1.20.4
|
||||
github.com/luxfi/dex v1.14.0
|
||||
github.com/luxfi/evm v1.99.51
|
||||
github.com/luxfi/geth v1.17.12
|
||||
github.com/luxfi/ids v1.2.15
|
||||
@@ -64,7 +65,7 @@ require (
|
||||
github.com/aws/smithy-go v1.24.2 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.3.6 // indirect
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.5.0 // indirect
|
||||
github.com/btcsuite/btcd/btcutil v1.1.6 // indirect
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
|
||||
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
|
||||
@@ -141,7 +142,6 @@ require (
|
||||
github.com/luxfi/container v0.0.4 // indirect
|
||||
github.com/luxfi/corona v0.10.3 // indirect
|
||||
github.com/luxfi/crypto/ipa v1.2.4 // indirect
|
||||
github.com/luxfi/dex v1.5.20 // indirect
|
||||
github.com/luxfi/dkg v0.3.5 // indirect
|
||||
github.com/luxfi/fhe v1.8.2 // indirect
|
||||
github.com/luxfi/filesystem v0.0.1 // indirect
|
||||
|
||||
@@ -65,6 +65,7 @@ github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v5
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE=
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.3.6 h1:IzlsEr9olcSRKB/n7c4351F3xHKxS2lma+1UFGCYd4E=
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.3.6/go.mod h1:m22FrOAiuxl/tht9wIqAoGHcbnCCaPWyauO8y2LGGtQ=
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.5.0/go.mod h1:+K/MYXcLBtHEQjRbjHuJChuybk4LCgjdjgRwil+e+Kk=
|
||||
github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A=
|
||||
github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE=
|
||||
github.com/btcsuite/btcd/btcutil v1.1.5/go.mod h1:PSZZ4UitpLBWzxGd5VGOrLnmOjtPP/a6HaFo12zMs00=
|
||||
@@ -321,6 +322,8 @@ github.com/luxfi/database v1.20.4 h1:WOt2GIGJxf8AFpg49odMz8DZ8RFSLDrozGhZtmorN70
|
||||
github.com/luxfi/database v1.20.4/go.mod h1:S/LvmfzNYWVNslcEcZwDrntqUO2ksaL8ql1nRmLUA/Q=
|
||||
github.com/luxfi/dex v1.5.20 h1:Vu3D0iS1QcAPs1DtPjjtQpd+8bclkf5nisVYqaoEbkA=
|
||||
github.com/luxfi/dex v1.5.20/go.mod h1:95k6Tl5HoyBRVd6IFzGlPOZE6SGqOyjnh4dSrJcplzM=
|
||||
github.com/luxfi/dex v1.14.0 h1:4PtorVbRmbD73S6YWPvgp5GRCb9jeuaedl7mo1CbzCI=
|
||||
github.com/luxfi/dex v1.14.0/go.mod h1:wYWQmwospvdKBWQ6OJMXb8I+kfgEtE46R1rD8H7vHCQ=
|
||||
github.com/luxfi/dkg v0.3.5 h1:s2L2mMQaz+n9m0b0ghvoV5VZNxiwb2z4WrGugvK0udY=
|
||||
github.com/luxfi/dkg v0.3.5/go.mod h1:M+WH7GFRN+YUD851Rlnumdp0Md98kplNN8pVx65U8I8=
|
||||
github.com/luxfi/evm v1.99.51 h1:Ai/++j0hK95YOrBGH/4ZWznO5C6yL9aRqwnaZ83BrbQ=
|
||||
|
||||
Reference in New Issue
Block a user