mirror of
https://github.com/luxfi/maker.git
synced 2026-10-30 16:20:04 +00:00
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]>
796 lines
24 KiB
Go
796 lines
24 KiB
Go
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")
|
||
}
|
||
}
|