Files
maker/seeder_test.go
zeekayandHanzo Dev 1ac4c806b2 Go 1.27.1, and the code it can say more simply
Modules declare go 1.27.1 and the build images follow. go fix applied the
modernizers; omitzero is not applied, since it changes what the JSON says.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-16 19:23:15 -07:00

796 lines
24 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package maker
import (
"bytes"
"context"
"encoding/binary"
"io"
"log/slog"
"math"
"slices"
"sort"
"sync"
"testing"
"time"
"github.com/luxfi/rpc"
)
// seeder_test.go exercises the maker's seed path end-to-end against a REAL
// clob_* ZAP server keyed by poolId — the same surface the venue D-Chain
// (github.com/luxfi/dex/pkg/dchain) serves via RegisterCLOB. The fake server
// below decodes the FROZEN frame byte-for-byte (poolId[32], place=65, cancel=40,
// submit=66, fills count[4]+N×17) exactly as dchain/handler_test.go does, runs an
// honest price-time book, and returns the frozen ack/fills frames. So a passing
// test proves the maker frames clob_ensure_market/place/cancel/submit
// byte-identically to what the venue consumes — not a stub.
// clobOrder is one resting order in the fake venue book.
type clobOrder struct {
id uint64
side uint8
price float64
size float64
}
// clobBook is a real price-time order book for one poolId: best bid = highest
// price, best ask = lowest price, FIFO within a price by insertion (id) order.
// Order ids are minted by the server-wide allocator (mintID), NOT a per-book
// counter — modeling the venue, which derives ids from blockDeterministicID
// (height, txIndex) and so makes order ids GLOBALLY unique across markets.
type clobBook struct {
mu sync.Mutex
mint func() uint64
orders map[uint64]*clobOrder
}
func newClobBook(mint func() uint64) *clobBook {
return &clobBook{mint: mint, orders: map[uint64]*clobOrder{}}
}
func (b *clobBook) place(side uint8, price, size float64) uint64 {
b.mu.Lock()
defer b.mu.Unlock()
id := b.mint()
b.orders[id] = &clobOrder{id: id, side: side, price: price, size: size}
return id
}
func (b *clobBook) cancel(id uint64) bool {
b.mu.Lock()
defer b.mu.Unlock()
if _, ok := b.orders[id]; ok {
delete(b.orders, id)
return true
}
return false
}
// cross matches a marketable order against the book and returns the fills,
// mutating resting size. An IOC limit (isMarket=false) only crosses prices that
// satisfy limitPrice; residual is dropped.
func (b *clobBook) cross(takerSide uint8, isMarket bool, limitPrice, size float64) []Fill {
b.mu.Lock()
defer b.mu.Unlock()
remaining := size
var fills []Fill
makerSide := sideSell
if takerSide == sideSell {
makerSide = sideBuy
}
var resting []*clobOrder
for _, o := range b.orders {
if o.side == makerSide {
resting = append(resting, o)
}
}
sort.Slice(resting, func(i, j int) bool {
if resting[i].price != resting[j].price {
if takerSide == sideBuy {
return resting[i].price < resting[j].price
}
return resting[i].price > resting[j].price
}
return resting[i].id < resting[j].id
})
for _, o := range resting {
if remaining <= 0 {
break
}
if !isMarket {
if takerSide == sideBuy && o.price > limitPrice {
break
}
if takerSide == sideSell && o.price < limitPrice {
break
}
}
take := math.Min(remaining, o.size)
fills = append(fills, Fill{Price: o.price, Size: take, TakerSide: takerSide})
o.size -= take
remaining -= take
if o.size <= 0 {
delete(b.orders, o.id)
}
}
return fills
}
func (b *clobBook) counts() (bids, asks int) {
b.mu.Lock()
defer b.mu.Unlock()
for _, o := range b.orders {
if o.side == sideBuy {
bids++
} else {
asks++
}
}
return bids, asks
}
func (b *clobBook) best(side uint8) (price, size float64) {
b.mu.Lock()
defer b.mu.Unlock()
for _, o := range b.orders {
if o.side != side {
continue
}
if side == sideBuy { // best bid = highest
if o.price > price {
price, size = o.price, o.size
}
} else { // best ask = lowest
if price == 0 || o.price < price {
price, size = o.price, o.size
}
}
}
return price, size
}
// clobServer routes clob_* frames to one clobBook per poolId — mirroring the
// venue's poolId-keyed market map. It proves the maker can ensure + seed
// multiple markets (LUX/LUSD, LUX/LETH) on one venue. The server owns a single
// monotonic id allocator shared by every book, so order ids are globally unique
// across markets exactly as the venue's blockDeterministicID guarantees.
//
// CUSTODY model (mirrors pkg/dchain): it keys a per-(user,asset) available
// balance, binds each market's (base,quote) via clob_open_market, and on a place
// LOCKS orderLock(side,price,size) from available — REJECTING a place it cannot
// fund. So a passing custody seed proves the maker (a) opened the market and (b)
// deposited enough that every order is accepted, exactly as the venue requires.
// Markets opened with assets are "custody" markets; markets only ensure_market'd
// stay asset-LESS and skip the balance check (the venue's no-custody fallback).
type clobServer struct {
mu sync.Mutex
nextID uint64
books map[[32]byte]*clobBook
// custody ledger.
bound map[[32]byte][2][32]byte // poolId -> {base, quote} (open market)
available map[ledgerKey]uint64 // (user16, asset) -> available balance
seenRef map[[32]byte]bool // deposit idempotency refs already applied
// callLog records the ordered sequence of custody/order methods so a test can
// assert open_market + deposit are issued BEFORE the first place.
callLog []string
}
// logCall appends a method name to the ordered call log.
func (s *clobServer) logCall(method string) {
s.mu.Lock()
s.callLog = append(s.callLog, method)
s.mu.Unlock()
}
// calls returns a copy of the ordered call log.
func (s *clobServer) calls() []string {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]string, len(s.callLog))
copy(out, s.callLog)
return out
}
// ledgerKey is a (16-byte user, 32-byte asset) balance key, mirroring the
// venue's userKey16+asset keyspace.
type ledgerKey struct {
user [16]byte
asset [32]byte
}
func userKey16(user string) [16]byte {
var k [16]byte
copy(k[:], user)
return k
}
func newClobServer() *clobServer {
return &clobServer{
books: map[[32]byte]*clobBook{},
bound: map[[32]byte][2][32]byte{},
available: map[ledgerKey]uint64{},
seenRef: map[[32]byte]bool{},
}
}
// mintID is the server-wide monotonic order-id source.
func (s *clobServer) mintID() uint64 {
s.mu.Lock()
defer s.mu.Unlock()
s.nextID++
return s.nextID
}
func (s *clobServer) book(pool [32]byte) *clobBook {
s.mu.Lock()
bk, ok := s.books[pool]
if !ok {
bk = newClobBook(s.mintID)
s.books[pool] = bk
}
s.mu.Unlock()
return bk
}
func poolFromPayload(payload []byte) [32]byte {
var p [32]byte
copy(p[:], payload[0:poolIDSize])
return p
}
func (s *clobServer) handleEnsureMarket(_ context.Context, payload []byte) ([]byte, error) {
if len(payload) < ensureMarketReqSize {
return ackBytes(0, statusRejected), nil
}
s.book(poolFromPayload(payload)) // idempotent create
return ackBytes(0, statusPlaced), nil
}
// handleOpenMarket binds a market's (base, quote) asset ids. Idempotent. Mirrors
// the venue's clob_open_market.
func (s *clobServer) handleOpenMarket(_ context.Context, payload []byte) ([]byte, error) {
s.logCall(methodOpenMarket)
if len(payload) < openMarketReqSize {
return ackBytes(0, statusRejected), nil
}
var pool, base, quote [32]byte
copy(pool[:], payload[0:poolIDSize])
copy(base[:], payload[poolIDSize:poolIDSize+assetIDSize])
copy(quote[:], payload[poolIDSize+assetIDSize:poolIDSize+2*assetIDSize])
s.mu.Lock()
s.bound[pool] = [2][32]byte{base, quote}
s.mu.Unlock()
s.book(pool) // ensure the book exists
return ackBytes(0, statusPlaced), nil
}
// handleDeposit credits available[user,asset] += amount, deduping by ref so a
// byte-identical retry is exactly-once. Mirrors the venue's clob_deposit, and
// replies with the frozen balance frame (status[1]+amount[8]).
func (s *clobServer) handleDeposit(_ context.Context, payload []byte) ([]byte, error) {
s.logCall(methodDeposit)
if len(payload) < depositReqSize {
return balanceResp(statusRejected, 0), nil
}
user := string(bytes.TrimRight(payload[0:userSize], "\x00"))
var asset, ref [32]byte
copy(asset[:], payload[userSize:userSize+assetIDSize])
amount := binary.BigEndian.Uint64(payload[userSize+assetIDSize : userSize+assetIDSize+8])
copy(ref[:], payload[userSize+assetIDSize+8:userSize+assetIDSize+8+refSize])
s.mu.Lock()
defer s.mu.Unlock()
if s.seenRef[ref] {
return balanceResp(statusPlaced, amount), nil // idempotent replay
}
s.seenRef[ref] = true
s.available[ledgerKey{userKey16(user), asset}] += amount
return balanceResp(statusPlaced, amount), nil
}
// orderLock mirrors pkg/dchain.orderLock: a sell locks base=floor(size); a buy
// locks quote=ceil(floor(size)*round(price*1e8)/1e8). It is the SAME math the
// maker's lockUnits uses, so a correctly-funded seed locks exactly its deposit.
func orderLock(side uint8, price, size float64, base, quote [32]byte) (asset [32]byte, amount uint64) {
amt := lockUnits(side, price, size)
if side == sideSell {
return base, amt
}
return quote, amt
}
func (s *clobServer) handlePlace(_ context.Context, payload []byte) ([]byte, error) {
s.logCall(methodPlace)
if len(payload) < placeReqSize {
return ackBytes(0, statusRejected), nil
}
pool := poolFromPayload(payload)
side := payload[poolIDSize]
price := getFloat64(payload[poolIDSize+1 : poolIDSize+9])
size := getFloat64(payload[poolIDSize+9 : poolIDSize+17])
user := string(bytes.TrimRight(payload[poolIDSize+17:poolIDSize+17+userSize], "\x00"))
if price <= 0 || size <= 0 {
return ackBytes(0, statusRejected), nil
}
// CUSTODY: if the market is asset-bound, the order must lock its spend from
// the maker's available balance — reject (insufficient funds) if it cannot.
// This is what makes "places accepted only when funded" a REAL assertion.
s.mu.Lock()
binding, custody := s.bound[pool]
if custody {
lockAsset, lockAmt := orderLock(side, price, size, binding[0], binding[1])
key := ledgerKey{userKey16(user), lockAsset}
if s.available[key] < lockAmt {
s.mu.Unlock()
return ackBytes(0, statusRejected), nil
}
s.available[key] -= lockAmt // lock: move out of available
}
s.mu.Unlock()
id := s.book(pool).place(side, price, size)
return ackBytes(id, statusPlaced), nil
}
// availableOf reports the maker's available balance of an asset (test helper).
func (s *clobServer) availableOf(user string, asset [32]byte) uint64 {
s.mu.Lock()
defer s.mu.Unlock()
return s.available[ledgerKey{userKey16(user), asset}]
}
func (s *clobServer) handleCancel(_ context.Context, payload []byte) ([]byte, error) {
if len(payload) < cancelReqSize {
return ackBytes(0, statusRejected), nil
}
pool := poolFromPayload(payload)
id := binary.BigEndian.Uint64(payload[poolIDSize : poolIDSize+8])
if s.book(pool).cancel(id) {
return ackBytes(id, statusCanceled), nil
}
return ackBytes(id, statusRejected), nil
}
func (s *clobServer) handleSubmit(_ context.Context, payload []byte) ([]byte, error) {
if len(payload) < submitReqSize {
return encodeFillsResp(nil), nil
}
pool := poolFromPayload(payload)
side := payload[poolIDSize]
isMarket := payload[poolIDSize+1] == 1
limitPrice := getFloat64(payload[poolIDSize+2 : poolIDSize+10])
size := getFloat64(payload[poolIDSize+10 : poolIDSize+18])
fills := s.book(pool).cross(side, isMarket, limitPrice, size)
return encodeFillsResp(fills), nil
}
// ackBytes builds the frozen 24-byte ack (orderId[8]+status[1]+seq[8]+pad[7]).
func ackBytes(id uint64, status uint8) []byte {
b := make([]byte, 24)
binary.BigEndian.PutUint64(b[0:8], id)
b[8] = status
return b
}
// balanceResp builds the frozen deposit/withdraw response (status[1]+amount[8]).
func balanceResp(status uint8, amount uint64) []byte {
b := make([]byte, balanceRespSize)
b[0] = status
binary.BigEndian.PutUint64(b[1:9], amount)
return b
}
// encodeFillsResp builds the frozen clob_submit response: count[4]+N×17.
func encodeFillsResp(fills []Fill) []byte {
out := make([]byte, 4+len(fills)*fillWireSize)
binary.BigEndian.PutUint32(out[0:4], uint32(len(fills)))
off := 4
for _, f := range fills {
putFloat64(out[off:off+8], f.Price)
putFloat64(out[off+8:off+16], f.Size)
out[off+16] = f.TakerSide
off += fillWireSize
}
return out
}
// startCLOB launches a real clob_* ZAP server on a random port.
func startCLOB(t *testing.T) (string, *clobServer) {
t.Helper()
srv, err := rpc.Listen("127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
s := newClobServer()
reg := func(method string, h rpc.RawHandler) {
if err := srv.RegisterRaw(method, h); err != nil {
t.Fatalf("register %s: %v", method, err)
}
}
reg(methodEnsureMarket, s.handleEnsureMarket)
reg(methodPlace, s.handlePlace)
reg(methodCancel, s.handleCancel)
reg(methodSubmit, s.handleSubmit)
reg(methodOpenMarket, s.handleOpenMarket)
reg(methodDeposit, s.handleDeposit)
addr := srv.Addr()
ctx := context.Background()
served := make(chan struct{})
go func() {
defer close(served)
srv.Serve(ctx)
}()
// Block until the server is accepting AND has processed a real request, so a
// later Close() does not race Serve's startup.
var probe [32]byte
probe[0] = 0x01
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
c, err := rpc.Dial(ctx, addr)
if err == nil {
if _, perr := c.CallRaw(ctx, methodEnsureMarket, encodeEnsureMarket(probe)); perr == nil {
c.Close()
break
}
c.Close()
}
time.Sleep(5 * time.Millisecond)
}
t.Cleanup(func() {
srv.Close()
<-served
})
return addr, s
}
func quietLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
func seedConfig() Config {
cfg := DefaultConfig()
cfg.SpreadBps = 10
cfg.Levels = 3
cfg.LevelSpacingBps = 5
cfg.BaseQty = 100
cfg.QtyMultiplier = 1.0
cfg.MakerUser = "maker"
cfg.Pairs = []Pair{
{Symbol: "LUX/LUSD", Mid: 12.50},
{Symbol: "LUX/LETH", Mid: 0.004},
}
return cfg
}
// TestSeedCreatesAndQuotesBothPairs is the core seed-path test: it stands up a
// real clob_* venue server, dials it as the maker would dial the venue D-Chain,
// seeds LUX/LUSD + LUX/LETH, and asserts both poolId-keyed markets now have a
// real 2-sided book at the expected depth and prices.
func TestSeedCreatesAndQuotesBothPairs(t *testing.T) {
ctx := context.Background()
addr, srv := startCLOB(t)
dex, err := DialDEX(ctx, addr, "maker")
if err != nil {
t.Fatalf("dial dex: %v", err)
}
defer dex.Close()
cfg := seedConfig()
seeder := NewSeeder(cfg, dex, &MidSpreadStrategy{}, quietLogger())
placed, err := seeder.Seed(ctx)
if err != nil {
t.Fatalf("seed: %v", err)
}
// 2 pairs * (3 bids + 3 asks) = 12 orders.
if placed != 12 {
t.Fatalf("placed = %d, want 12", placed)
}
for _, p := range cfg.Pairs {
bk := srv.book(SymbolPoolID(p.Symbol))
nb, na := bk.counts()
if nb != cfg.Levels || na != cfg.Levels {
t.Errorf("%s: book has %d bids / %d asks, want %d/%d", p.Symbol, nb, na, cfg.Levels, cfg.Levels)
}
bid, bsz := bk.best(sideBuy)
ask, asz := bk.best(sideSell)
// Best level must straddle the seed mid at exactly SpreadBps.
wantBid := roundTo8(p.Mid - p.Mid*(cfg.SpreadBps/10000))
wantAsk := roundTo8(p.Mid + p.Mid*(cfg.SpreadBps/10000))
if bid != wantBid {
t.Errorf("%s best bid = %v, want %v", p.Symbol, bid, wantBid)
}
if ask != wantAsk {
t.Errorf("%s best ask = %v, want %v", p.Symbol, ask, wantAsk)
}
if bid >= ask {
t.Errorf("%s crossed book: bid %v >= ask %v", p.Symbol, bid, ask)
}
if bsz != cfg.BaseQty || asz != cfg.BaseQty {
t.Errorf("%s best sizes = %v/%v, want %v", p.Symbol, bsz, asz, cfg.BaseQty)
}
mid := roundTo8((bid + ask) / 2)
if mid != roundTo8(p.Mid) {
t.Errorf("%s seeded mid = %v, want %v", p.Symbol, mid, p.Mid)
}
}
}
// custodyConfig is seedConfig with the canonical devnet asset ids bound and
// custody enabled — the production seeding shape for LUX/LUSD + LUX/LETH.
func custodyConfig() Config {
cfg := seedConfig()
cfg.Pairs = []Pair{
{Symbol: "LUX/LUSD", Mid: 12.50, Base: AssetLUX, Quote: AssetLUSD, Custody: true},
{Symbol: "LUX/LETH", Mid: 0.004, Base: AssetLUX, Quote: AssetLETH, Custody: true},
}
return cfg
}
// TestSeedCustodyFundsAndOpensBeforePlace is the DEFECT-2 proof: a custody seed
// (a) routes to the venue-convention poolId, (b) issues clob_open_market and
// clob_deposit BEFORE any clob_place, and (c) the maker is funded enough that
// every place is ACCEPTED (the server rejects an underfunded place, so 12 placed
// orders means 12 funded locks). It is the real custody-backed seed path.
func TestSeedCustodyFundsAndOpensBeforePlace(t *testing.T) {
ctx := context.Background()
addr, srv := startCLOB(t)
dex, err := DialDEX(ctx, addr, "maker")
if err != nil {
t.Fatalf("dial dex: %v", err)
}
defer dex.Close()
cfg := custodyConfig()
seeder := NewSeeder(cfg, dex, &MidSpreadStrategy{}, quietLogger())
placed, err := seeder.Seed(ctx)
if err != nil {
t.Fatalf("seed: %v", err)
}
if placed != 12 { // 2 pairs * (3 bids + 3 asks)
t.Fatalf("placed = %d, want 12 (every order funded & accepted)", placed)
}
// (a) Each pair seeded the venue-convention poolId — the SAME id the FIX/WS/
// precompile rails address — and that book has the full 2-sided depth.
for _, p := range cfg.Pairs {
pid := SymbolPoolID(p.Symbol)
if dex.PoolID(p.Symbol) != pid {
t.Errorf("%s: client poolId != venue convention", p.Symbol)
}
nb, na := srv.book(pid).counts()
if nb != cfg.Levels || na != cfg.Levels {
t.Errorf("%s @venue-poolId: %d/%d depth, want %d/%d", p.Symbol, nb, na, cfg.Levels, cfg.Levels)
}
}
// (b) open_market + deposit precede the first place. Find the first place and
// assert at least one open_market and one deposit happened before it.
calls := srv.calls()
firstPlace := -1
for i, m := range calls {
if m == methodPlace {
firstPlace = i
break
}
}
if firstPlace < 0 {
t.Fatal("no clob_place observed")
}
var sawOpen, sawDeposit bool
for _, m := range calls[:firstPlace] {
switch m {
case methodOpenMarket:
sawOpen = true
case methodDeposit:
sawDeposit = true
}
}
if !sawOpen || !sawDeposit {
t.Errorf("before first place: open_market=%v deposit=%v; want both true (calls=%v)", sawOpen, sawDeposit, calls)
}
// (c) The maker funded EXACTLY the locks: after placing, available is fully
// consumed into locks (deposit == sum of locks). Recompute the expected
// deposit and confirm the post-seed available is zero for each asset.
for _, p := range cfg.Pairs {
bids, asks, err := (&MidSpreadStrategy{}).Quote(ctx, &AggregatedBook{Symbol: p.Symbol, MidPrice: p.Mid}, 0, cfg)
if err != nil {
t.Fatal(err)
}
var baseLock, quoteLock uint64
for _, o := range asks {
baseLock += lockUnits(sideSell, roundTo8(o.Price), roundTo8(o.Qty))
}
for _, o := range bids {
quoteLock += lockUnits(sideBuy, roundTo8(o.Price), roundTo8(o.Qty))
}
if baseLock == 0 || quoteLock == 0 {
t.Fatalf("%s: degenerate lock base=%d quote=%d", p.Symbol, baseLock, quoteLock)
}
// Deposited base then locked it on the asks -> available base back to 0.
if av := srv.availableOf("maker", p.Base); av != 0 {
t.Errorf("%s: available base = %d after placing, want 0 (deposit==locks)", p.Symbol, av)
}
if av := srv.availableOf("maker", p.Quote); av != 0 {
t.Errorf("%s: available quote = %d after placing, want 0 (deposit==locks)", p.Symbol, av)
}
}
}
// TestSeedRejectsUnderfundedPlace proves the custody check is REAL: if the maker
// is NOT funded, the venue rejects the place and Seed surfaces the error — so a
// passing TestSeedCustodyFundsAndOpensBeforePlace genuinely depends on the
// deposit. We drive the DEXClient directly against a custody-bound market with no
// deposit and assert the place is rejected.
func TestSeedRejectsUnderfundedPlace(t *testing.T) {
ctx := context.Background()
addr, _ := startCLOB(t)
dex, err := DialDEX(ctx, addr, "maker")
if err != nil {
t.Fatalf("dial: %v", err)
}
defer dex.Close()
// Bind the market (custody) but DO NOT deposit.
if err := dex.OpenMarket(ctx, "LUX/LUSD", AssetLUX, AssetLUSD); err != nil {
t.Fatalf("open market: %v", err)
}
// A sell locks base; with zero available base the venue must reject it.
if _, err := dex.PlaceLimit(ctx, "LUX/LUSD", "sell", 12.5125, 100); err == nil {
t.Fatal("expected reject placing on an unfunded custody market")
}
// After a sufficient deposit, the SAME place is accepted.
if err := dex.Deposit(ctx, AssetLUX, 100); err != nil {
t.Fatalf("deposit: %v", err)
}
if _, err := dex.PlaceLimit(ctx, "LUX/LUSD", "sell", 12.5125, 100); err != nil {
t.Fatalf("funded place rejected: %v", err)
}
}
// TestSubmitCrossesSeededBook proves the seeded liquidity is REAL and the
// clob_submit frame is byte-identical: a marketable buy against LUX/LUSD crosses
// the resting asks the maker placed and returns consensus-shaped fills.
func TestSubmitCrossesSeededBook(t *testing.T) {
ctx := context.Background()
addr, _ := startCLOB(t)
dex, err := DialDEX(ctx, addr, "maker")
if err != nil {
t.Fatalf("dial: %v", err)
}
defer dex.Close()
cfg := seedConfig()
seeder := NewSeeder(cfg, dex, &MidSpreadStrategy{}, quietLogger())
if _, err := seeder.Seed(ctx); err != nil {
t.Fatalf("seed: %v", err)
}
// Best ask for LUX/LUSD is mid*(1+10bps)=12.5125 size 100. A 40-unit IOC buy
// at a price above the best ask must fill 40 @ 12.5125 from that level.
fills, err := dex.Submit(ctx, "LUX/LUSD", "buy", false, 12.52, 40)
if err != nil {
t.Fatalf("submit: %v", err)
}
if len(fills) != 1 {
t.Fatalf("fills = %d, want 1", len(fills))
}
if fills[0].Size != 40 {
t.Errorf("fill size = %v, want 40", fills[0].Size)
}
if fills[0].Price != roundTo8(12.50+12.50*0.001) {
t.Errorf("fill price = %v, want %v", fills[0].Price, roundTo8(12.50+12.50*0.001))
}
if fills[0].TakerSide != sideBuy {
t.Errorf("taker side = %d, want buy", fills[0].TakerSide)
}
}
// TestMaintainCancelsAndRequotes proves the maintenance cycle keeps the book at
// constant depth: prior orders are cancelled (poolId-keyed clob_cancel) and
// replaced, not stacked.
func TestMaintainCancelsAndRequotes(t *testing.T) {
ctx := context.Background()
addr, srv := startCLOB(t)
dex, err := DialDEX(ctx, addr, "maker")
if err != nil {
t.Fatalf("dial: %v", err)
}
defer dex.Close()
cfg := seedConfig()
seeder := NewSeeder(cfg, dex, &MidSpreadStrategy{}, quietLogger())
if _, err := seeder.Seed(ctx); err != nil {
t.Fatalf("seed: %v", err)
}
firstIDs := seeder.LiveOrders("LUX/LUSD")
if len(firstIDs) != 2*cfg.Levels {
t.Fatalf("first live = %d, want %d", len(firstIDs), 2*cfg.Levels)
}
for cycle := range 3 {
if _, err := seeder.Maintain(ctx); err != nil {
t.Fatalf("maintain %d: %v", cycle, err)
}
bk := srv.book(SymbolPoolID("LUX/LUSD"))
nb, na := bk.counts()
if nb != cfg.Levels || na != cfg.Levels {
t.Fatalf("after maintain %d: %d/%d depth, want %d/%d", cycle, nb, na, cfg.Levels, cfg.Levels)
}
}
// Order ids must have advanced (cancel+replace), not be reused.
newIDs := seeder.LiveOrders("LUX/LUSD")
slices.Sort(firstIDs)
slices.Sort(newIDs)
if newIDs[0] <= firstIDs[len(firstIDs)-1] {
t.Errorf("ids not refreshed: first max %d, new min %d", firstIDs[len(firstIDs)-1], newIDs[0])
}
}
// TestBoundPoolSeedsExactMarket proves an explicit Pair.PoolID overrides the
// venue-convention SymbolPoolID default, so the maker seeds the exact on-chain
// book a deployed V4 PoolKey resolves to.
func TestBoundPoolSeedsExactMarket(t *testing.T) {
ctx := context.Background()
addr, srv := startCLOB(t)
dex, err := DialDEX(ctx, addr, "maker")
if err != nil {
t.Fatalf("dial: %v", err)
}
defer dex.Close()
var pinned [32]byte
pinned[0], pinned[31] = 0xfe, 0x01
cfg := seedConfig()
cfg.Pairs = []Pair{{Symbol: "LUX/LUSD", Mid: 12.50, PoolID: pinned}}
seeder := NewSeeder(cfg, dex, &MidSpreadStrategy{}, quietLogger())
if _, err := seeder.Seed(ctx); err != nil {
t.Fatalf("seed: %v", err)
}
// The pinned poolId book is seeded; the venue-convention default is NOT.
nb, na := srv.book(pinned).counts()
if nb != cfg.Levels || na != cfg.Levels {
t.Errorf("pinned book = %d/%d, want %d/%d", nb, na, cfg.Levels, cfg.Levels)
}
if dnb, dna := srv.book(SymbolPoolID("LUX/LUSD")).counts(); dnb != 0 || dna != 0 {
t.Errorf("default venue-convention book unexpectedly seeded: %d/%d", dnb, dna)
}
}
// TestRejectedOrderSurfacesError ensures invalid orders propagate as errors.
func TestRejectedOrderSurfacesError(t *testing.T) {
ctx := context.Background()
addr, _ := startCLOB(t)
dex, err := DialDEX(ctx, addr, "maker")
if err != nil {
t.Fatalf("dial: %v", err)
}
defer dex.Close()
if _, err := dex.PlaceLimit(ctx, "LUX/LUSD", "buy", -1, 10); err == nil {
t.Fatal("expected error for negative price")
}
}