Files
dex/pkg/dchain/read_test.go
T
zeekayandHanzo Dev 5a808d27f7 chain routes: /v1/bc -> /v1/chain
luxd serves chain routes under /v1/chain, and the node, cli and sdk releases
already ship it. The deployed gateways still answer the old prefix, so remote
reads break until they are redeployed; the estate moves together rather than
sitting half converted.

Lines that record an observation - a probe, a status code, a past release -
keep the prefix they were written against, as do URLs on hosts that are not
ours. Avalanche endpoints move to /ext/bc, which is the path that host serves.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-01 08:21:06 -07:00

505 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dchain
import (
"context"
"encoding/binary"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/luxfi/consensus/engine/chain/block"
"github.com/luxfi/database/memdb"
"github.com/luxfi/dex/pkg/dex"
"github.com/luxfi/dex/pkg/zapwire"
"github.com/luxfi/log"
)
// read_test.go proves the D-Chain READ surface (read.go / dex_get_*):
//
// 1. The read endpoints are reachable over the EXACT in-luxd HTTP transport
// (CreateHandlers, mounted at /v1/chain/<D>/dex/<method>) — the same routes a
// deployed validator exposes.
// 2. After a crossing order is matched into a committed block (via the write
// path + the auto-sealer playing consensus), dex_get_trades returns the
// committed fill, dex_get_markets lists the market, dex_get_orders/book
// reflect the resting set.
// 3. CROSS-VALIDATOR INVARIANT (in-process model): two INDEPENDENT VMs that
// Accept the SAME block bytes return BYTE-IDENTICAL dex_get_trades JSON and
// the same head root. This is the local proof of the property the live
// 5-validator test checks: same accepted block => same committed read on
// every node. (A follower in production reaches the same state by Verify+
// Accept of the gossiped bytes — integration.go HandleIncomingBlock.)
// 4. Reads have ZERO consensus impact: a read does not change height or the
// mempool.
// readDeps bundles the asset ids + identities a custody market needs.
type readFixture struct {
pool [32]byte
base, quote [32]byte
}
func newReadFixture() readFixture {
f := readFixture{pool: poolIDForSymbol("LUX/LUSD")}
// base = AssetLUX (all-zero); quote = a labelled 32-byte asset id.
binary.BigEndian.PutUint64(f.quote[24:32], 0x4c555344_000001)
return f
}
// startReadServer brings up a VM + the in-luxd HTTP handlers (writes AND reads)
// over a real httptest.Server, plus the auto-sealer. Returns the VM, the base
// URL (.../dex), and a stop func. Mirrors startIngestServer (ingest_test.go) but
// is self-contained so the read tests do not depend on the write test's harness.
func startReadServer(t *testing.T) (*VM, string, func()) {
t.Helper()
db := memdb.New()
vm := &VM{}
toEngine := make(chan block.Message, 64)
if err := vm.Initialize(context.Background(), block.Init{
Runtime: testRuntime(),
Genesis: []byte(testDocument),
DB: db, Log: log.NewNoOpLogger(), ToEngine: toEngine, Config: authConfig(t),
}); err != nil {
t.Fatalf("Initialize: %v", err)
}
handlers, err := vm.CreateHandlers(context.Background())
if err != nil {
t.Fatalf("CreateHandlers: %v", err)
}
root := http.NewServeMux()
for path, h := range handlers {
root.Handle(path, h)
}
srv := httptest.NewServer(root)
ctx, cancel := context.WithCancel(context.Background())
var wg sync.WaitGroup
wg.Add(1)
go func() { defer wg.Done(); autoSealer(ctx, t, vm) }()
stop := func() {
cancel()
srv.Close()
_ = vm.Shutdown(context.Background())
wg.Wait()
}
return vm, srv.URL, stop
}
// httpPost posts a frozen frame to a write method and returns the raw response.
func httpPost(t *testing.T, base, method string, payload []byte) []byte {
t.Helper()
req, err := http.NewRequest(http.MethodPost, methodURL(base, method), byteReader(payload))
if err != nil {
t.Fatalf("new req %s: %v", method, err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("post %s: %v", method, err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("post %s: status %d body=%s", method, resp.StatusCode, body)
}
return body
}
// httpGetJSON GETs a read method (with optional raw query) and decodes the JSON
// body into out. Fails on non-200.
func httpGetJSON(t *testing.T, base, method, query string, out interface{}) {
t.Helper()
url := methodURL(base, method)
if query != "" {
url += "?" + query
}
resp, err := http.Get(url)
if err != nil {
t.Fatalf("get %s: %v", method, err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("get %s: status %d body=%s", method, resp.StatusCode, body)
}
if err := json.Unmarshal(body, out); err != nil {
t.Fatalf("get %s: decode %v body=%s", method, err, body)
}
}
func byteReader(b []byte) *byteReaderT { return &byteReaderT{b: b} }
type byteReaderT struct {
b []byte
off int
}
func (r *byteReaderT) Read(p []byte) (int, error) {
if r.off >= len(r.b) {
return 0, io.EOF
}
n := copy(p, r.b[r.off:])
r.off += n
return n, nil
}
// crossOneMarket runs the full write path that produces one committed fill:
// open_market (bind assets), deposits, alice rests an ask, bob crosses with a
// marketable buy. Returns the fills bob's submit reported.
func crossOneMarket(t *testing.T, base string, f readFixture) []zapwire.Fill {
t.Helper()
const aliceUser, bobUser = "alice", "bob"
const askPrice, askSize = 12.50, 2.0
const buyLimit, buySize = 13.0, 1.0
httpPost(t, base, zapwire.MethodOpenMarket, zapwire.EncodeOpenMarket(f.pool, f.base, f.quote))
// Every money-moving frame is signed by the user's key-derived account (the auth
// gate refuses unsigned deposit/place/submit); the wire user is the account.
dep := func(user string, asset [32]byte, amt uint64) {
ref := contentRef(byte(TxDeposit), user, asset, amt)
body := zapwire.EncodeDeposit(wireUser(t, user), asset, amt, ref)
// A deposit is authorized by the trusted bridge AUTHORITY (F9), not the user.
httpPost(t, base, zapwire.MethodDeposit, signedPayload(t, depositAuthorityName, TxDeposit, body))
}
dep(aliceUser, f.base, 2)
dep(bobUser, f.quote, 53)
askResp := httpPost(t, base, zapwire.MethodPlace,
signedPayload(t, aliceUser, TxPlace, encPlace(f.pool, zapwire.SideSell, askPrice, askSize, wireUser(t, aliceUser))))
if askResp[8] != zapwire.StatusPlaced {
t.Fatalf("ask not placed: %x", askResp)
}
subResp := httpPost(t, base, zapwire.MethodSubmit,
signedPayload(t, bobUser, TxSubmit, encSubmit(f.pool, zapwire.SideBuy, false, buyLimit, buySize, wireUser(t, bobUser))))
fills, err := zapwire.DecodeFills(subResp)
if err != nil {
t.Fatalf("decode fills: %v (resp=%x)", err, subResp)
}
if len(fills) == 0 {
t.Fatalf("cross produced 0 fills (resp=%x)", subResp)
}
return fills
}
// TestRead_TradesAfterCommittedFill is the core happy path: a crossing order is
// matched into a committed block, and dex_get_trades returns that fill from
// durable state with the right price/size/side and a non-genesis head.
func TestRead_TradesAfterCommittedFill(t *testing.T) {
vm, base, stop := startReadServer(t)
defer stop()
f := newReadFixture()
fills := crossOneMarket(t, base, f)
t.Logf("submit reported %d fill(s): price=%d/1e8 size=%d", len(fills), fills[0].Price, fills[0].Size)
var got getTradesResp
httpGetJSON(t, base, MethodGetTrades, "", &got)
if got.Count == 0 || len(got.Trades) == 0 {
t.Fatalf("dex_get_trades returned no committed trades after a fill: %+v", got)
}
if got.Height == 0 {
t.Fatalf("head height still 0 after a committed fill (head=%+v)", got.chainHead)
}
tr := got.Trades[0]
// The read API renders human floats; the wire fill carries exact integers
// (price = PriceInt ×1e8, size = base units). Compare in human units.
wantPx := float64(fills[0].Price) / float64(zapwire.PriceScale)
wantSz := float64(fills[0].Size)
if tr.Price != wantPx {
t.Errorf("committed trade price %g != submit fill price %g", tr.Price, wantPx)
}
if tr.Size != wantSz {
t.Errorf("committed trade size %g != submit fill size %g", tr.Size, wantSz)
}
if tr.TakerSide != "buy" {
t.Errorf("taker side = %q, want buy", tr.TakerSide)
}
if tr.Height == 0 || tr.TradeID == 0 {
t.Errorf("trade row missing coordinates: %+v", tr)
}
// The read head must match the VM's authoritative last-accepted height.
vm.mu.Lock()
wantHeight := vm.lastAcceptedHeight
wantRoot := hex.EncodeToString(vm.lastRoot[:])
vm.mu.Unlock()
if got.Height != wantHeight {
t.Errorf("read head height %d != VM %d", got.Height, wantHeight)
}
if got.Root != wantRoot {
t.Errorf("read head root %s != VM %s", got.Root, wantRoot)
}
}
// TestRead_MarketsAndOrdersAndBook proves the other three read endpoints reflect
// committed state: the market is listed (with bound assets), and the resting book
// (alice's partially-consumed ask remainder) shows up in orders + book.
func TestRead_MarketsAndOrdersAndBook(t *testing.T) {
_, base, stop := startReadServer(t)
defer stop()
f := newReadFixture()
_ = crossOneMarket(t, base, f) // ask size 2, buy 1 -> 1.0 base remains resting
marketHex := hex.EncodeToString(f.pool[:])
// markets
var markets getMarketsResp
httpGetJSON(t, base, MethodGetMarkets, "", &markets)
if markets.Count == 0 {
t.Fatalf("dex_get_markets returned no markets")
}
var found *marketJSON
for i := range markets.Markets {
if markets.Markets[i].PoolID == marketHex {
found = &markets.Markets[i]
break
}
}
if found == nil {
t.Fatalf("market %s not in dex_get_markets: %+v", marketHex, markets.Markets)
}
if !found.AssetsBound {
t.Errorf("market assetsBound=false, want true (open_market bound them)")
}
if found.Orders == 0 {
t.Errorf("market shows 0 resting orders, want the ask remainder")
}
// orders
var orders getOrdersResp
httpGetJSON(t, base, MethodGetOrders, "market="+marketHex, &orders)
if orders.Count == 0 {
t.Fatalf("dex_get_orders returned no resting orders for %s", marketHex)
}
rem := orders.Orders[0]
if rem.Side != "sell" {
t.Errorf("resting order side=%q, want sell (alice's ask)", rem.Side)
}
if rem.Remaining <= 0 || rem.Remaining > rem.Size {
t.Errorf("resting remaining=%g out of range (size=%g)", rem.Remaining, rem.Size)
}
// book
var bookResp getBookResp
httpGetJSON(t, base, MethodGetBook, "market="+marketHex, &bookResp)
if bookResp.Orders == 0 {
t.Fatalf("dex_get_book shows 0 orders")
}
if len(bookResp.Asks) == 0 {
t.Fatalf("dex_get_book shows no asks (alice's ask remainder should rest)")
}
if bookResp.BestAsk <= 0 {
t.Errorf("bestAsk=%g, want the ask price", bookResp.BestAsk)
}
}
// TestRead_ZeroConsensusImpact proves a read never advances the chain or touches
// the mempool: many reads between two committed fills leave height advancing only
// by the writes, and the mempool empty after each read.
func TestRead_ZeroConsensusImpact(t *testing.T) {
vm, base, stop := startReadServer(t)
defer stop()
f := newReadFixture()
_ = crossOneMarket(t, base, f)
vm.mu.Lock()
h0 := vm.lastAcceptedHeight
vm.mu.Unlock()
for i := 0; i < 25; i++ {
var got getTradesResp
httpGetJSON(t, base, MethodGetTrades, "", &got)
var m getMarketsResp
httpGetJSON(t, base, MethodGetMarkets, "", &m)
}
vm.mu.Lock()
h1 := vm.lastAcceptedHeight
pending := vm.mempool.Len()
vm.mu.Unlock()
if h1 != h0 {
t.Errorf("reads advanced height %d -> %d (must be zero consensus impact)", h0, h1)
}
if pending != 0 {
t.Errorf("reads left %d txs in the mempool (must be zero)", pending)
}
}
// TestRead_CrossValidatorIdenticalTrades is the in-process model of the live
// 5-validator question: two INDEPENDENT VMs (separate DBs) that Accept the SAME
// sequence of block bytes must return BYTE-IDENTICAL dex_get_trades JSON and the
// same head root. This isolates the replication property from the network: if a
// block's bytes are delivered (gossip in production) and Verify+Accept succeed,
// the committed read is identical — which is exactly what the read RPC lets the
// live test confirm across luxd-0..4.
func TestRead_CrossValidatorIdenticalTrades(t *testing.T) {
f := newReadFixture()
// PROPOSER: run the full write path with an auto-sealer, capturing every
// accepted block's canonical bytes in order.
proposer, base, stop := startReadServer(t)
var (
capMu sync.Mutex
acceptedBz [][]byte
)
captureAccepted := func(vm *VM) {
vm.mu.Lock()
defer vm.mu.Unlock()
// Walk the height index in order, pulling each accepted block's bytes.
for h := uint64(1); h <= vm.lastAcceptedHeight; h++ {
id, ok := vm.heightIndex[h]
if !ok {
continue
}
if blk, ok := vm.acceptedBlocks[id]; ok {
acceptedBz = append(acceptedBz, append([]byte(nil), blk.bytes...))
}
}
}
_ = crossOneMarket(t, base, f)
// Let the sealer settle, then snapshot the accepted chain bytes + proposer read.
time.Sleep(200 * time.Millisecond)
capMu.Lock()
captureAccepted(proposer)
capMu.Unlock()
var proposerTrades getTradesResp
httpGetJSON(t, base, MethodGetTrades, "", &proposerTrades)
stop()
if len(acceptedBz) == 0 {
t.Fatalf("captured no accepted blocks from proposer")
}
if proposerTrades.Count == 0 {
t.Fatalf("proposer has no committed trades")
}
// FOLLOWER: a fresh VM (separate DB, no sealer) that replays the proposer's
// accepted block bytes through ParseBlock -> Verify -> Accept — exactly the
// integration.go HandleIncomingBlock fast-follow path, minus the network.
followerDB := memdb.New()
follower := &VM{}
if err := follower.Initialize(context.Background(), block.Init{
Runtime: testRuntime(),
Genesis: []byte(testDocument),
DB: followerDB, Log: log.NewNoOpLogger(), ToEngine: make(chan block.Message, 64), Config: authConfig(t),
}); err != nil {
t.Fatalf("follower Initialize: %v", err)
}
defer follower.Shutdown(context.Background())
for i, bz := range acceptedBz {
blk, err := follower.ParseBlock(context.Background(), bz)
if err != nil {
t.Fatalf("follower ParseBlock[%d]: %v", i, err)
}
if err := blk.Verify(context.Background()); err != nil {
t.Fatalf("follower Verify[%d] (height %d): %v", i, blk.Height(), err)
}
if err := blk.Accept(context.Background()); err != nil {
t.Fatalf("follower Accept[%d] (height %d): %v", i, blk.Height(), err)
}
}
// Read the follower's committed trades through the SAME handler and compare.
followerHandlers, _ := follower.CreateHandlers(context.Background())
fmux := http.NewServeMux()
for path, h := range followerHandlers {
fmux.Handle(path, h)
}
fsrv := httptest.NewServer(fmux)
defer fsrv.Close()
var followerTrades getTradesResp
httpGetJSON(t, fsrv.URL, MethodGetTrades, "", &followerTrades)
// The replicated state must be IDENTICAL: same head root + same trades.
if followerTrades.Root != proposerTrades.Root {
t.Fatalf("follower root %s != proposer root %s (state diverged)", followerTrades.Root, proposerTrades.Root)
}
if followerTrades.Height != proposerTrades.Height {
t.Fatalf("follower height %d != proposer height %d", followerTrades.Height, proposerTrades.Height)
}
pj, _ := json.Marshal(proposerTrades.Trades)
fj, _ := json.Marshal(followerTrades.Trades)
if string(pj) != string(fj) {
t.Fatalf("cross-validator trade mismatch:\n proposer=%s\n follower=%s", pj, fj)
}
t.Logf("cross-validator OK: %d trade(s) identical at height %d root %s",
followerTrades.Count, followerTrades.Height, followerTrades.Root[:16])
}
// TestRead_GenesisEmpty proves a fresh chain answers reads cleanly: empty trades,
// no markets, genesis head — no nil-deref, no error.
func TestRead_GenesisEmpty(t *testing.T) {
_, base, stop := startReadServer(t)
defer stop()
var trades getTradesResp
httpGetJSON(t, base, MethodGetTrades, "", &trades)
if trades.Count != 0 || len(trades.Trades) != 0 {
t.Errorf("fresh chain has trades: %+v", trades)
}
var markets getMarketsResp
httpGetJSON(t, base, MethodGetMarkets, "", &markets)
if markets.Count != 0 {
t.Errorf("fresh chain has markets: %+v", markets)
}
// An unknown market's orders/book are empty, not an error.
none := poolIDForSymbol("NONE/NONE")
unknown := hex.EncodeToString(none[:])
var orders getOrdersResp
httpGetJSON(t, base, MethodGetOrders, "market="+unknown, &orders)
if orders.Count != 0 {
t.Errorf("unknown market has orders: %+v", orders)
}
}
// TestRead_BadParams proves boundary validation: a malformed market id is a 400,
// not a panic or a silent wrong-market read.
func TestRead_BadParams(t *testing.T) {
_, base, stop := startReadServer(t)
defer stop()
// missing market on an endpoint that requires it.
resp, err := http.Get(methodURL(base, MethodGetOrders))
if err != nil {
t.Fatalf("get: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("missing market: status %d, want 400", resp.StatusCode)
}
// wrong-length market id.
resp2, err := http.Get(methodURL(base, MethodGetBook) + "?market=deadbeef")
if err != nil {
t.Fatalf("get: %v", err)
}
resp2.Body.Close()
if resp2.StatusCode != http.StatusBadRequest {
t.Errorf("short market: status %d, want 400", resp2.StatusCode)
}
// a POST to a read endpoint is rejected (reads are GET).
resp3, err := http.Post(methodURL(base, MethodGetTrades), "application/json", byteReader(nil))
if err != nil {
t.Fatalf("post: %v", err)
}
resp3.Body.Close()
if resp3.StatusCode != http.StatusMethodNotAllowed {
t.Errorf("POST to read endpoint: status %d, want 405", resp3.StatusCode)
}
}
// compile-time guard: the read row codec stays consistent with the lx trade row.
var _ = dex.DEXTrade{}