mirror of
https://github.com/luxfi/graph.git
synced 2026-09-24 00:26:07 +00:00
250 lines
10 KiB
Go
250 lines
10 KiB
Go
package indexer
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"math/big"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
)
|
|
|
|
// clobStub is an in-process D-Chain read-RPC double serving the exact JSON shapes
|
|
// of luxfi/dex pkg/dchain/read.go (captured live from devnet). It lets the CLOB
|
|
// source be driven deterministically with no node. Trades are served filtered by
|
|
// the `since` cursor so the incremental drain is exercised honestly.
|
|
type clobStub struct {
|
|
mu sync.Mutex
|
|
marketsBody string
|
|
trades []string // each is a ready trade JSON object, height-ordered
|
|
ordersBody map[string]string
|
|
hits map[string]int
|
|
}
|
|
|
|
func newCLOBStub() *clobStub {
|
|
return &clobStub{ordersBody: map[string]string{}, hits: map[string]int{}}
|
|
}
|
|
|
|
func (s *clobStub) server(t *testing.T) *httptest.Server {
|
|
t.Helper()
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
method := strings.TrimPrefix(r.URL.Path, "/")
|
|
s.hits[method]++
|
|
w.Header().Set("Content-Type", "application/json")
|
|
switch method {
|
|
case "dex_get_markets":
|
|
fmt.Fprint(w, s.marketsBody)
|
|
case "dex_get_trades":
|
|
var since uint64
|
|
fmt.Sscanf(r.URL.Query().Get("since"), "%d", &since)
|
|
rows := make([]string, 0, len(s.trades))
|
|
for _, tr := range s.trades {
|
|
var h uint64
|
|
// cheap height extract from the canned row
|
|
fmt.Sscanf(tr, `{"height":%d`, &h)
|
|
if h >= since {
|
|
rows = append(rows, tr)
|
|
}
|
|
}
|
|
fmt.Fprintf(w, `{"height":209,"lastAccepted":"abc","root":"deadbeef","count":%d,"trades":[%s]}`,
|
|
len(rows), strings.Join(rows, ","))
|
|
case "dex_get_orders":
|
|
m := r.URL.Query().Get("market")
|
|
if body, ok := s.ordersBody[m]; ok {
|
|
fmt.Fprint(w, body)
|
|
return
|
|
}
|
|
fmt.Fprintf(w, `{"height":209,"lastAccepted":"abc","root":"deadbeef","market":%q,"count":0,"orders":[]}`, m)
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
return srv
|
|
}
|
|
|
|
// liveMarketsBody is the dex_get_markets body captured from lux-devnet luxd-0:
|
|
// one unbound pool, two bound markets (LUX/LETH, LUX/LUSD) whose hex symbols
|
|
// decode to human pair names.
|
|
const liveMarketsBody = `{"height":209,"lastAccepted":"YtNQ","root":"dba7","count":3,"markets":[` +
|
|
`{"poolId":"32bb228d59d9770be8c4ec1153267135f294bfcc0a96bab8f54c408e3735cebd","symbol":"32bb228d59d9770be8c4ec1153267135f294bfcc0a96bab8f54c408e3735cebd","assetsBound":false,"orders":0,"remaining":0,"bestBid":0,"bestAsk":0},` +
|
|
`{"poolId":"4c55582f4c4554480000000000000000000000000000000000000000000000d0","symbol":"4c55582f4c4554480000000000000000000000000000000000000000000000d0","base":"00","quote":"01","assetsBound":true,"orders":10,"remaining":26.375,"bestBid":0.003996,"bestAsk":0.004004},` +
|
|
`{"poolId":"4c55582f4c5553440000000000000000000000000000000000000000000000d0","symbol":"4c55582f4c5553440000000000000000000000000000000000000000000000d0","base":"00","quote":"01","assetsBound":true,"orders":21,"remaining":133.375,"bestBid":12.4875,"bestAsk":5}` +
|
|
`]}`
|
|
|
|
// TestCLOB_IndexesNativeMarketsTradesOrders is the end-to-end proof: a CLOB source
|
|
// pointed at the read-RPC double populates Market/Fill/Order entities the dex
|
|
// resolvers serve, with the bound-market symbol decoded to its human pair name.
|
|
func TestCLOB_IndexesNativeMarketsTradesOrders(t *testing.T) {
|
|
s := newMemSQLiteStore(t)
|
|
stub := newCLOBStub()
|
|
stub.marketsBody = liveMarketsBody
|
|
stub.trades = []string{
|
|
`{"height":172,"seq":0,"tradeId":1,"price":5,"size":10,"takerSide":"buy","makerOrderId":519691042817,"takerOrderId":738734374913,"timestamp":1782163644410312965}`,
|
|
`{"height":179,"seq":0,"tradeId":1,"price":5,"size":10,"takerSide":"buy","makerOrderId":549755813889,"takerOrderId":768799145985,"timestamp":1782163752630157863}`,
|
|
}
|
|
bound := "4c55582f4c5553440000000000000000000000000000000000000000000000d0"
|
|
stub.ordersBody[bound] = `{"height":209,"lastAccepted":"abc","root":"deadbeef","market":"` + bound + `","count":2,"orders":[` +
|
|
`{"orderId":12,"side":"buy","price":12.4875,"size":100,"remaining":100,"user":"aabb"},` +
|
|
`{"orderId":13,"side":"sell","price":5,"size":33.375,"remaining":33.375,"user":"ccdd"}]}`
|
|
srv := stub.server(t)
|
|
|
|
c := NewCLOBSource(srv.URL, s)
|
|
if err := c.poll(context.Background()); err != nil {
|
|
t.Fatalf("poll: %v", err)
|
|
}
|
|
|
|
// Markets: 3 present, keyed by poolID.
|
|
mkts, _ := s.ListByType("Market", 100)
|
|
if mkts == nil || len(mkts.([]interface{})) != 3 {
|
|
t.Fatalf("want 3 markets, got %v", mkts)
|
|
}
|
|
// The bound LUX/LUSD market carries a human symbol + live book summary.
|
|
mk, _ := s.GetByType("Market", bound)
|
|
if mk == nil {
|
|
t.Fatal("LUX/LUSD market missing")
|
|
}
|
|
mm := mk.(map[string]interface{})
|
|
if mm["symbol"] != "LUX/LUSD" {
|
|
t.Errorf("symbol = %v, want LUX/LUSD (decoded from hex)", mm["symbol"])
|
|
}
|
|
if mm["assetsBound"] != true {
|
|
t.Errorf("assetsBound = %v, want true", mm["assetsBound"])
|
|
}
|
|
if fmt.Sprint(mm["bestBid"]) != "12.4875" || fmt.Sprint(mm["bestAsk"]) != "5" {
|
|
t.Errorf("book summary = bid %v ask %v, want 12.4875/5", mm["bestBid"], mm["bestAsk"])
|
|
}
|
|
// An unbound pool keeps its hex poolID as the symbol (no fabricated label).
|
|
unbound := "32bb228d59d9770be8c4ec1153267135f294bfcc0a96bab8f54c408e3735cebd"
|
|
um, _ := s.GetByType("Market", unbound)
|
|
if um.(map[string]interface{})["symbol"] != unbound {
|
|
t.Errorf("unbound market symbol = %v, want raw poolID", um.(map[string]interface{})["symbol"])
|
|
}
|
|
|
|
// Fills: 2 from the trade log, keyed height:seq, with full trade rows.
|
|
fills, _ := s.ListByType("Fill", 100)
|
|
if fills == nil || len(fills.([]interface{})) != 2 {
|
|
t.Fatalf("want 2 fills, got %v", fills)
|
|
}
|
|
f, _ := s.GetByType("Fill", "172:0")
|
|
if f == nil {
|
|
t.Fatal("fill 172:0 missing")
|
|
}
|
|
fm := f.(map[string]interface{})
|
|
if fm["price"] != "5" || fm["size"] != "10" || fm["side"] != "buy" {
|
|
t.Errorf("fill 172:0 = price %v size %v side %v, want 5/10/buy", fm["price"], fm["size"], fm["side"])
|
|
}
|
|
if fm["takerOrderId"] != "738734374913" {
|
|
t.Errorf("fill takerOrderId = %v, want 738734374913", fm["takerOrderId"])
|
|
}
|
|
|
|
// Orders: 2 resting on the bound market, namespaced poolID#orderId.
|
|
ords, _ := s.ListByType("Order", 100)
|
|
if ords == nil || len(ords.([]interface{})) != 2 {
|
|
t.Fatalf("want 2 orders, got %v", ords)
|
|
}
|
|
o, _ := s.GetByType("Order", bound+"#12")
|
|
if o == nil {
|
|
t.Fatal("order #12 missing")
|
|
}
|
|
om := o.(map[string]interface{})
|
|
if om["market"] != bound || om["side"] != "buy" || om["price"] != "12.4875" {
|
|
t.Errorf("order #12 = market %v side %v price %v", om["market"], om["side"], om["price"])
|
|
}
|
|
}
|
|
|
|
// TestCLOB_IncrementalAndIdempotent proves the trade cursor advances (a second
|
|
// poll re-requests only since>=lastHeight+1) and that re-ingesting a trade is an
|
|
// idempotent overwrite (the Fill set does not grow on a re-read of the same row).
|
|
func TestCLOB_IncrementalAndIdempotent(t *testing.T) {
|
|
s := newMemSQLiteStore(t)
|
|
stub := newCLOBStub()
|
|
stub.marketsBody = `{"height":1,"lastAccepted":"a","root":"b","count":0,"markets":[]}`
|
|
stub.trades = []string{
|
|
`{"height":10,"seq":0,"tradeId":1,"price":5,"size":1,"takerSide":"buy","makerOrderId":1,"takerOrderId":2,"timestamp":1}`,
|
|
}
|
|
srv := stub.server(t)
|
|
c := NewCLOBSource(srv.URL, s)
|
|
|
|
if err := c.poll(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if c.lastTradeHeight != 10 {
|
|
t.Fatalf("cursor must advance to 10, got %d", c.lastTradeHeight)
|
|
}
|
|
fills, _ := s.ListByType("Fill", 100)
|
|
if len(fills.([]interface{})) != 1 {
|
|
t.Fatalf("want 1 fill after poll 1, got %d", len(fills.([]interface{})))
|
|
}
|
|
|
|
// A new fill at a higher height arrives; the old one is still served (since<=10
|
|
// not yet advanced past it) — the Fill set must end at 2, not 3 (no double-count
|
|
// of the height-10 row on its re-read).
|
|
stub.mu.Lock()
|
|
stub.trades = append(stub.trades,
|
|
`{"height":12,"seq":0,"tradeId":1,"price":6,"size":2,"takerSide":"sell","makerOrderId":3,"takerOrderId":4,"timestamp":2}`)
|
|
stub.mu.Unlock()
|
|
|
|
if err := c.poll(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if c.lastTradeHeight != 12 {
|
|
t.Fatalf("cursor must advance to 12, got %d", c.lastTradeHeight)
|
|
}
|
|
fills, _ = s.ListByType("Fill", 100)
|
|
if len(fills.([]interface{})) != 2 {
|
|
t.Fatalf("idempotent re-read: want 2 fills, got %d", len(fills.([]interface{})))
|
|
}
|
|
}
|
|
|
|
// TestCLOB_AdditiveToEVM is the no-regression guard: an indexer configured with
|
|
// DexRPC still constructs the EVM poller (rpc-based) unchanged, AND a CLOB source.
|
|
// The EVM-side AMM path (handleSwapV4 etc.) is untouched — proven by feeding an
|
|
// AMM swap log through processLog and seeing the swap recorded, with the CLOB
|
|
// source present but driving only Market/Fill/Order. AMM and DEX never collide.
|
|
func TestCLOB_AdditiveToEVM(t *testing.T) {
|
|
s := newMemSQLiteStore(t)
|
|
idx := NewWithConfig(Config{RPC: "http://unused", DexRPC: "http://node/v1/chain/D/dex"}, s)
|
|
if idx.clob == nil {
|
|
t.Fatal("DexRPC set must construct a CLOB source")
|
|
}
|
|
|
|
// The EVM AMM path still works: a canonical 0x9999 V4 swap is recorded as an
|
|
// AMM swap (the CLOB source touches none of this).
|
|
poolID := topic32("abcd")
|
|
taker := "0x00000000000000000000000000000000000000aa"
|
|
idx.processLog(context.Background(), &logEntry{
|
|
Address: LXSettleAddress,
|
|
Topics: []string{SigSwapV4, poolID, addrTopic(taker)},
|
|
Data: "0x" + signedWord(big.NewInt(1000)) + signedWord(big.NewInt(-2500)) + word(big.NewInt(0)) + word(big.NewInt(0)) + word(big.NewInt(0)) + word(big.NewInt(3000)),
|
|
BlockNumber: "0x10",
|
|
TransactionHash: "0xdead",
|
|
LogIndex: "0x0",
|
|
})
|
|
sw, _ := s.GetSwaps(nil, 10, "timestamp", "desc", nil)
|
|
if sw == nil || len(sw.([]interface{})) != 1 {
|
|
t.Fatalf("EVM AMM swap must still be recorded with a CLOB source present, got %v", sw)
|
|
}
|
|
}
|
|
|
|
// TestDecodeMarketSymbol covers the bound→human and unbound→hex projection.
|
|
func TestDecodeMarketSymbol(t *testing.T) {
|
|
cases := []struct{ in, want string }{
|
|
{"4c55582f4c5553440000000000000000000000000000000000000000000000d0", "LUX/LUSD"},
|
|
{"4c55582f4c4554480000000000000000000000000000000000000000000000d0", "LUX/LETH"},
|
|
// Unbound pool: random 32-byte poolID hex has no "/" → keep the hex id.
|
|
{"32bb228d59d9770be8c4ec1153267135f294bfcc0a96bab8f54c408e3735cebd",
|
|
"32bb228d59d9770be8c4ec1153267135f294bfcc0a96bab8f54c408e3735cebd"},
|
|
{"0xdeadbeef", "0xdeadbeef"}, // not a pair, odd content → unchanged
|
|
}
|
|
for _, tc := range cases {
|
|
if got := decodeMarketSymbol(tc.in); got != tc.want {
|
|
t.Errorf("decodeMarketSymbol(%s) = %q, want %q", tc.in, got, tc.want)
|
|
}
|
|
}
|
|
}
|