dex(cmd/dexseed): canonical REAL-market seeder over the native D-Chain seam

cmd/dexseed seeds REAL markets onto the live in-luxd native D-Chain CLOB
ingestion seam (POST /ext/bc/D/dex/clob_* with FROZEN zapwire frames),
using consensus-native asset identities for every market side.

Identity rule (the property that makes this seeder SAFE against a shared
cluster, unlike the loopback-gated cmd/clobverify): every asset handle is
the dexcore canonical AssetID = DeriveAssetID(networkID, C-chainID, kind,
canonicalRef) — the SAME id the chain's resolver derives — NEVER an
ascii-of-symbol id (the synthetic form, e.g. 0x4c5558 'LUX', that bricked
the chain). A synthetic/malformed ref is refused at frame-build time,
before a single frame is sent; the chain's permissionless resolver then
proves each side is backed by live on-chain code (EXTCODESIZE) before
binding the market.

End-to-end smoke path for one real LUX/<ERC20> market: clob_open_market
(bind real base+quote ids) -> clob_deposit (fund maker base) -> clob_place
(rest maker ask) -> clob_get_markets + clob_get_book (read back & confirm).

Pure, testable core (seed.go): assetSpec.resolveID, poolIDFor (canonical
domain-separated SHA-256 over the derived ids, NON-ascii), depositRefFor,
realMarketFrames. Tests (main_test.go): golden AssetID vectors, OpenMarket
96B + slot equality, synthetic/malformed-ref rejection, deterministic
non-ascii poolID, frozen-frame field round-trips. CGO_ENABLED=0, pure-Go,
luxfi packages only.
This commit is contained in:
2026-06-23 15:09:01 -07:00
parent bcf4d0a4f5
commit 93fa24e705
3 changed files with 894 additions and 0 deletions
+345
View File
@@ -0,0 +1,345 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Command dexseed is the CANONICAL real-market seeder for the live native D-Chain
// DEX. It seeds REAL markets onto the in-luxd CLOB ingestion seam (POST
// /ext/bc/D/dex/clob_* with FROZEN zapwire frames) using REAL, consensus-native asset
// identities — dexcore.DeriveAssetID(networkID, C-chainID, kind, canonicalRef) — for
// every side of every market.
//
// CONTRAST WITH cmd/clobverify: clobverify is a LOCAL-ONLY SYNTHETIC harness. It seeds
// an ASCII-of-symbol poolID and an all-zero / label-shaped asset (e.g. "LUX/LUSD" ->
// 0x4c55582f4c555344...). If those synthetic frames reach a shared D-Chain validator
// set they persist into the replication snapshot and BRICK the native VM on the next
// boot (assertOrderUserCoverage refuses a resting order on a market with no real asset
// row). clobverify is therefore gated by internal/localguard to loopback only.
//
// dexseed is DIFFERENT and SAFE against a shared cluster: it NEVER builds an
// ascii-of-symbol id. Every asset it opens is admitted ONLY if its (kind, ref) derives
// a well-formed canonical AssetID via dexcore.DeriveAssetID — the SAME identity the
// chain's resolver computes — and the chain's own permissionless resolver additionally
// proves each side is backed by live on-chain code (the EXTCODESIZE reality gate)
// before binding the market. A synthetic/malformed ref is refused HERE, at frame-build
// time, before a single frame is sent. So dexseed needs no localguard opt-in; its
// safety is structural, not a loopback fence.
//
// END-TO-END SMOKE PATH (one real market):
//
// 1. clob_open_market(poolID, baseID, quoteID) bind the REAL (base,quote)
// 2. clob_deposit(maker, baseID, amount, ref) fund the maker's base balance
// 3. clob_place(poolID, SELL, askPrice, askSize, maker) rest a maker limit ask
// 4. clob_get_markets / clob_get_book?market=<poolID> read back & confirm
//
// EXAMPLE (devnet, one real LUX/<ERC20> market against a single validator):
//
// dexseed \
// -node http://localhost:9650 \
// -network-id 3 \
// -cchain-id Bm8X7THQtS2txLrQWTDXN8a4JDuhP4KGybUE754LLSiVFLQ7v \
// -quote-erc20 0x<20-byte-token-address> \
// -maker maker
//
// The base is EVM_NATIVE LUX (the EVM zero-address native marker). The quote is the
// real ERC-20 at -quote-erc20 (it MUST be a token actually deployed on the C-Chain;
// the chain's reality gate rejects a non-deployed address and the open is refused with
// HTTP 502). Omitting -quote-erc20 prints what to pass and exits — there is no
// synthetic fallback by design.
package main
import (
"bytes"
"encoding/hex"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/luxfi/dex/pkg/zapwire"
"github.com/luxfi/ids"
)
// Read endpoint sub-path keys. These mirror pkg/dchain.MethodGetMarkets / MethodGetBook
// — the external HTTP routes the native VM registers under /ext/bc/D/dex/. They are
// declared here (not imported) so the seeder reads the JSON schema exactly as an
// external consumer (the indexer) would, never coupling to the VM package — the same
// discipline cmd/clobverify uses for the read surface.
const (
methodGetMarkets = "clob_get_markets"
methodGetBook = "clob_get_book"
)
// httpClient is a short-timeout client. A write blocks in the VM's submitTx until the
// order's block is accepted, so the timeout must comfortably exceed one consensus
// round; 30s is the same bound cmd/clobverify uses.
var httpClient = &http.Client{Timeout: 30 * time.Second}
// chainHead mirrors pkg/dchain.chainHead — the accepted-block anchor every read body
// carries.
type chainHead struct {
Height uint64 `json:"height"`
LastAccepted string `json:"lastAccepted"`
Root string `json:"root"`
}
// marketJSON mirrors pkg/dchain.marketJSON (one row of clob_get_markets).
type marketJSON struct {
PoolID string `json:"poolId"`
Symbol string `json:"symbol"`
AssetsBound bool `json:"assetsBound"`
Orders int `json:"orders"`
Remaining float64 `json:"remaining"`
BestBid float64 `json:"bestBid"`
BestAsk float64 `json:"bestAsk"`
}
// getMarketsResp mirrors pkg/dchain.getMarketsResp (clob_get_markets body).
type getMarketsResp struct {
chainHead
Count int `json:"count"`
Markets []marketJSON `json:"markets"`
}
// levelJSON mirrors pkg/dchain.levelJSON (one aggregated book level).
type levelJSON struct {
Price float64 `json:"price"`
Size float64 `json:"size"`
Count int `json:"count"`
}
// getBookResp mirrors pkg/dchain.getBookResp (clob_get_book body).
type getBookResp struct {
chainHead
Market string `json:"market"`
Orders int `json:"orders"`
Remaining float64 `json:"remaining"`
BestBid float64 `json:"bestBid"`
BestAsk float64 `json:"bestAsk"`
Bids []levelJSON `json:"bids"`
Asks []levelJSON `json:"asks"`
}
func die(format string, args ...any) {
fmt.Fprintf(os.Stderr, "FAIL: "+format+"\n", args...)
os.Exit(1)
}
// postFrame POSTs a FROZEN zapwire frame to <base>/ext/bc/D/dex/<method> as
// application/octet-stream and returns the raw response bytes (the zapwire ack / fills
// / balance frame). base is http://host:port (no trailing slash). This is the exact
// posting style cmd/clobverify uses, pointed at the same in-luxd ingestion seam.
func postFrame(base, method string, payload []byte) ([]byte, error) {
url := base + "/ext/bc/D/dex/" + method
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/octet-stream")
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s: HTTP %d: %s", method, resp.StatusCode, strings.TrimSpace(string(body)))
}
return body, nil
}
// getJSON GETs <base>/ext/bc/D/dex/<method>[?query] and decodes the JSON body.
func getJSON(base, method, query string, out any) error {
url := base + "/ext/bc/D/dex/" + method
if query != "" {
url += "?" + query
}
resp, err := httpClient.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("%s: HTTP %d: %s", method, resp.StatusCode, strings.TrimSpace(string(body)))
}
return json.Unmarshal(body, out)
}
// parseERC20Addr parses a 0x-prefixed (or bare) 20-byte hex token address. A
// non-20-byte or non-hex value is an error — there is NO synthetic fallback.
func parseERC20Addr(s string) ([]byte, error) {
s = strings.TrimSpace(s)
if s == "" {
return nil, fmt.Errorf("empty token address")
}
if len(s) >= 2 && (s[0:2] == "0x" || s[0:2] == "0X") {
s = s[2:]
}
b, err := hex.DecodeString(s)
if err != nil {
return nil, fmt.Errorf("not hex: %w", err)
}
if len(b) != 20 {
return nil, fmt.Errorf("ERC-20 address must be 20 bytes, got %d", len(b))
}
return b, nil
}
func main() {
var (
nodeURL = flag.String("node", "", "single validator base URL, e.g. http://localhost:9650 (REQUIRED)")
networkID = flag.Uint("network-id", 3, "Lux network id the assets live under (3=devnet; mainnet=1, testnet=2)")
cChainID = flag.String("cchain-id", "", "C-Chain blockchain id (the sourceChainID for EVM_NATIVE/ERC20 asset derivation) (REQUIRED)")
quoteERC20 = flag.String("quote-erc20", "", "REAL ERC-20 token contract address (0x...20 bytes) to use as the market quote; the base is EVM_NATIVE LUX")
maker = flag.String("maker", "maker", "maker CLOB user handle (the 16-byte on-wire user, NOT an EVM key)")
askPrice = flag.Float64("ask-price", 1.0, "resting maker ask price (quote per base)")
askSize = flag.Float64("ask-size", 1.0, "resting maker ask size (base whole units)")
settle = flag.Duration("settle", 2*time.Second, "wait before reading back so the order's block is accepted")
)
flag.Parse()
node := strings.TrimRight(strings.TrimSpace(*nodeURL), "/")
if node == "" {
die("-node is required (single validator base URL, e.g. http://localhost:9650)")
}
if *cChainID == "" {
die("-cchain-id is required (the C-Chain blockchain id; asset ids are derived under it)")
}
cid, err := ids.FromString(*cChainID)
if err != nil {
die("-cchain-id %q: %v", *cChainID, err)
}
if *networkID == 0 || *networkID > 0xffffffff {
die("-network-id %d out of range for uint32", *networkID)
}
netID := uint32(*networkID)
if *quoteERC20 == "" {
die("-quote-erc20 is required: a REAL ERC-20 token address (0x...) to quote LUX against.\n" +
" dexseed NEVER fabricates a synthetic quote (that bricks the chain). Pass a token\n" +
" actually deployed on the C-Chain, e.g. -quote-erc20 0x<20-byte-token-address>.")
}
quoteAddr, err := parseERC20Addr(*quoteERC20)
if err != nil {
die("-quote-erc20 %q: %v", *quoteERC20, err)
}
base := nativeSpec() // EVM_NATIVE LUX (the chain's own coin)
quote := erc20Spec(quoteAddr) // REAL ERC-20 quote
// Build the frozen frames + validated plan. This step REFUSES any synthetic /
// malformed asset ref (the only ones it would accept derive a well-formed canonical
// AssetID), so we never send a poison frame.
depositAmount := depositAmountFor(*askSize)
// The deposit ref is derived after we know the poolID/baseID, so derive a provisional
// plan first to learn the ids, then thread the real ref. realMarketFrames is pure and
// cheap, so we call it twice: once to learn baseID, once with the bound ref.
_, _, _, plan0, err := realMarketFrames(netID, cid, base, quote, *maker, *askPrice, *askSize, depositAmount, [32]byte{})
if err != nil {
die("derive real market (asset admission): %v", err)
}
depRef := depositRefFor(plan0.PoolID, plan0.BaseID, depositAmount)
openMarket, deposit, place, plan, err := realMarketFrames(netID, cid, base, quote, *maker, *askPrice, *askSize, depositAmount, depRef)
if err != nil {
die("derive real market (asset admission): %v", err)
}
fmt.Printf("=== dexseed: REAL-market seeder ===\n")
fmt.Printf(" node : %s\n", node)
fmt.Printf(" network-id : %d\n", netID)
fmt.Printf(" C-Chain id : %s\n", cid)
fmt.Printf(" base (LUX) : kind=%s id=%s\n", base.Kind, plan.BaseID)
fmt.Printf(" quote(ERC20): kind=%s addr=0x%s id=%s\n", quote.Kind, hex.EncodeToString(quoteAddr), plan.QuoteID)
fmt.Printf(" poolID : 0x%s (canonical, NON-ascii)\n", hex.EncodeToString(plan.PoolID[:]))
fmt.Printf(" maker : %q ask %.6f x %.6f deposit base=%d\n", *maker, *askPrice, *askSize, depositAmount)
mustPost := func(step, method string, payload []byte) []byte {
b, err := postFrame(node, method, payload)
if err != nil {
die("%s (%s): %v", step, method, err)
}
return b
}
// ---- 1/3 open the REAL market (bind base+quote canonical ids) ----
mustPost("[1/3] open-market", zapwire.MethodOpenMarket, openMarket)
fmt.Printf("[1/3] clob_open_market: bound base+quote canonical ids (real-asset market)\n")
// ---- 2/3 fund the maker's base balance ----
depResp := mustPost("[2/3] deposit", zapwire.MethodDeposit, deposit)
depStatus, credited, derr := zapwire.DecodeBalanceResp(depResp)
if derr != nil {
die("[2/3] deposit: decode balance resp: %v (resp=%x)", derr, depResp)
}
fmt.Printf("[2/3] clob_deposit: maker base credited=%d (status=%d, ref=0x%s…)\n",
credited, depStatus, hex.EncodeToString(depRef[:4]))
// ---- 3/3 rest the maker limit ask ----
askResp := mustPost("[3/3] place", zapwire.MethodPlace, place)
orderID, ackStatus, aerr := zapwire.DecodeAck(askResp)
if aerr != nil {
die("[3/3] place: decode ack: %v (resp=%x)", aerr, askResp)
}
if ackStatus != zapwire.StatusPlaced {
die("[3/3] place: ask NOT placed (status=%d, resp=%x) — the maker base deposit may be insufficient to lock the ask",
ackStatus, askResp)
}
fmt.Printf("[3/3] clob_place: maker ask RESTING (orderId=%d, status=placed)\n", orderID)
// ---- read back: confirm the market + resting order landed ----
if *settle > 0 {
time.Sleep(*settle)
}
fmt.Printf("\n=== read-back: clob_get_markets + clob_get_book ===\n")
var mkts getMarketsResp
if err := getJSON(node, methodGetMarkets, "", &mkts); err != nil {
die("read clob_get_markets: %v", err)
}
poolHex := hex.EncodeToString(plan.PoolID[:])
var seen *marketJSON
for i := range mkts.Markets {
if strings.EqualFold(strings.TrimPrefix(mkts.Markets[i].PoolID, "0x"), poolHex) {
seen = &mkts.Markets[i]
break
}
}
if seen == nil {
die("clob_get_markets (height=%d, count=%d) does NOT list our poolID 0x%s — market did not commit",
mkts.Height, mkts.Count, poolHex)
}
fmt.Printf(" market: poolId=0x%s assetsBound=%v orders=%d bestAsk=%.6f (head height=%d)\n",
poolHex, seen.AssetsBound, seen.Orders, seen.BestAsk, mkts.Height)
if !seen.AssetsBound {
die("market is listed but assetsBound=false — the (base,quote) binding did not persist")
}
var book getBookResp
if err := getJSON(node, methodGetBook, "market=0x"+poolHex, &book); err != nil {
die("read clob_get_book: %v", err)
}
if book.Orders < 1 || len(book.Asks) < 1 {
die("clob_get_book shows NO resting ask (orders=%d, asks=%d) — the resting order did not land",
book.Orders, len(book.Asks))
}
fmt.Printf(" book : orders=%d remaining=%.6f bestAsk=%.6f (top ask: px=%.6f size=%.6f count=%d)\n",
book.Orders, book.Remaining, book.BestAsk, book.Asks[0].Price, book.Asks[0].Size, book.Asks[0].Count)
fmt.Printf("\nSEEDED: REAL market 0x%s is live on the native D-Chain with a resting maker ask.\n", poolHex)
fmt.Printf(" base=%s quote=%s — both canonical real-asset ids (NOT ascii-of-symbol).\n", plan.BaseID, plan.QuoteID)
}
// depositAmountFor returns a maker base deposit comfortably covering the resting ask
// lock. A SELL of askSize base units locks floor(askSize) base; we credit a generous
// whole-unit cushion so the place always funds. (The native D-Chain credits and locks
// in whole base units on the deposit/place rails.)
func depositAmountFor(askSize float64) uint64 {
amt := uint64(askSize) + 2
if amt < 2 {
amt = 2
}
return amt
}
+361
View File
@@ -0,0 +1,361 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package main
import (
"encoding/hex"
"testing"
"github.com/luxfi/dex/pkg/dexcore"
"github.com/luxfi/dex/pkg/zapwire"
"github.com/luxfi/ids"
)
// devnetCChain is the canonical devnet C-Chain blockchain id the seeder derives asset
// ids under. It is the same id the live devnet validators serve.
const devnetCChain = "Bm8X7THQtS2txLrQWTDXN8a4JDuhP4KGybUE754LLSiVFLQ7v"
// Golden AssetID vectors — pinned bytes the dexcore identity primitive MUST produce for
// fixed (networkID, C-chainID, kind, ref) tuples. These are NOT recomputed from the
// same call under test; they are CONSTANTS frozen here, so a drift in DeriveAssetID's
// fold (domain tag, length-prefix discipline, field order) breaks this test. The C-Chain
// id below decodes to 186f101d…91e57b71 (32 bytes).
const (
// goldenNativeID3 = DeriveAssetID(3, devnetCChain, EVM_NATIVE, EVMNativeMarker).
goldenNativeID3 = "b3e7122cf728552ca6e7336e95ade83c2f48711b7ac963cebe549afeaf6a73f4"
// goldenERC20ID3 = DeriveAssetID(3, devnetCChain, ERC20, 0x00..01) (20-byte address,
// last byte 0x01).
goldenERC20ID3 = "c7e26133e8a0c2201a65341b24b98446d6a5bd4dfa369a34682bfa6da2b4455b"
)
func mustCChain(t *testing.T) ids.ID {
t.Helper()
cid, err := ids.FromString(devnetCChain)
if err != nil {
t.Fatalf("parse devnet C-Chain id: %v", err)
}
return cid
}
func erc20Addr(lastByte byte) []byte {
a := make([]byte, 20)
a[19] = lastByte
return a
}
// TestGoldenAssetIDs is the GOLDEN VECTOR proof: DeriveAssetID produces the exact,
// frozen 32-byte ids for known tuples. This pins the consensus-native identity the
// seeder uses to the chain's own derivation — if either drifts, a seeded market would
// bind a different id than the chain admits, and this test fails first.
func TestGoldenAssetIDs(t *testing.T) {
cid := mustCChain(t)
natID, err := dexcore.DeriveAssetID(3, cid, dexcore.AssetKindEVMNative, dexcore.EVMNativeMarker)
if err != nil {
t.Fatalf("derive native id: %v", err)
}
if got := hex.EncodeToString(natID[:]); got != goldenNativeID3 {
t.Fatalf("native(3) AssetID drift:\n got %s\n want %s", got, goldenNativeID3)
}
ercID, err := dexcore.DeriveAssetID(3, cid, dexcore.AssetKindERC20, erc20Addr(0x01))
if err != nil {
t.Fatalf("derive erc20 id: %v", err)
}
if got := hex.EncodeToString(ercID[:]); got != goldenERC20ID3 {
t.Fatalf("erc20(3,..01) AssetID drift:\n got %s\n want %s", got, goldenERC20ID3)
}
// The two ids MUST differ (kind domain-separation): native and an ERC-20 are never
// the same asset even by accident.
if natID == ercID {
t.Fatal("native and ERC-20 ids collided — kind domain separation broken")
}
}
// TestResolveID_MatchesGolden proves the seeder's assetSpec.resolveID delegates to the
// SAME primitive: a native spec and an ERC-20 spec resolve to the golden ids.
func TestResolveID_MatchesGolden(t *testing.T) {
cid := mustCChain(t)
natID, err := nativeSpec().resolveID(3, cid)
if err != nil {
t.Fatalf("native resolveID: %v", err)
}
if got := hex.EncodeToString(natID[:]); got != goldenNativeID3 {
t.Fatalf("nativeSpec.resolveID drift:\n got %s\n want %s", got, goldenNativeID3)
}
ercID, err := erc20Spec(erc20Addr(0x01)).resolveID(3, cid)
if err != nil {
t.Fatalf("erc20 resolveID: %v", err)
}
if got := hex.EncodeToString(ercID[:]); got != goldenERC20ID3 {
t.Fatalf("erc20Spec.resolveID drift:\n got %s\n want %s", got, goldenERC20ID3)
}
}
// TestResolveID_RejectsSynthetic proves the seeder REFUSES synthetic / malformed asset
// refs at derivation time — the property that makes dexseed safe against a shared
// cluster. None of these can ever become a poolID or a bound asset.
func TestResolveID_RejectsSynthetic(t *testing.T) {
cid := mustCChain(t)
cases := []struct {
name string
spec assetSpec
}{
{
// THE BRICKING FORM: an ascii-of-symbol "address" — "LUX" left in a byte
// slice that is NOT a 20-byte token address.
name: "ascii-ticker-LUX (not 20 bytes)",
spec: assetSpec{Kind: dexcore.AssetKindERC20, Ref: []byte("LUX")},
},
{
// An ascii ticker right-shaped to 20 bytes is still NOT a real token, but the
// derivation can only refuse it on shape; the CHAIN's reality gate (no code)
// refuses the rest. We assert the seeder refuses the clearly-malformed shapes
// here and rely on the chain's EXTCODESIZE gate for shaped-but-fake. The
// all-zero ERC-20 address (== the native marker) MUST be refused locally.
name: "ERC20 zero address (== native marker, not a token)",
spec: assetSpec{Kind: dexcore.AssetKindERC20, Ref: make([]byte, 20)},
},
{
name: "ERC20 wrong length (33 bytes)",
spec: assetSpec{Kind: dexcore.AssetKindERC20, Ref: make([]byte, 33)},
},
{
name: "invalid kind (0)",
spec: assetSpec{Kind: dexcore.AssetKindInvalid, Ref: erc20Addr(0x01)},
},
{
name: "EVM_NATIVE with a non-marker ref",
spec: assetSpec{Kind: dexcore.AssetKindEVMNative, Ref: erc20Addr(0x01)},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if id, err := tc.spec.resolveID(3, cid); err == nil {
t.Fatalf("resolveID MUST reject %s, but it derived id %x", tc.name, id)
}
})
}
}
// TestPoolIDDeterministicAndNonAscii proves the poolID derivation is (a) deterministic
// and (b) structurally NOT an ascii-of-symbol id like cmd/clobverify's poolIDForSymbol.
func TestPoolIDDeterministicAndNonAscii(t *testing.T) {
cid := mustCChain(t)
baseID, err := nativeSpec().resolveID(3, cid)
if err != nil {
t.Fatalf("native resolveID: %v", err)
}
quoteID, err := erc20Spec(erc20Addr(0x01)).resolveID(3, cid)
if err != nil {
t.Fatalf("erc20 resolveID: %v", err)
}
p1 := poolIDFor(baseID, quoteID)
p2 := poolIDFor(baseID, quoteID)
if p1 != p2 {
t.Fatalf("poolIDFor is not deterministic: %x != %x", p1, p2)
}
// Order matters: (base,quote) and (quote,base) are distinct markets, so distinct ids.
if pSwapped := poolIDFor(quoteID, baseID); pSwapped == p1 {
t.Fatal("poolIDFor(base,quote) must differ from poolIDFor(quote,base)")
}
// NOT ascii-of-symbol: the synthetic form copies a ticker into the high bytes and sets
// the last byte to 0xD0 (clobverify.poolIDForSymbol). Our derivation must match neither
// the "LUX/<ERC20>" ascii prefix nor the 0xD0 suffix sentinel.
ascii := asciiPoolID("LUX/ERC20")
if p1 == ascii {
t.Fatal("poolID collided with an ascii-of-symbol poolID")
}
if p1[31] == 0xD0 {
t.Fatal("poolID ends in the 0xD0 synthetic sentinel — looks ascii-shaped")
}
// No leading run of printable-ascii ticker bytes (the ascii form starts with the
// symbol). A real hash is uniformly random; assert the first 8 bytes are not all in
// the printable-ascii band an ascii ticker would occupy.
allPrintable := true
for _, b := range p1[:8] {
if b < 0x20 || b > 0x7e {
allPrintable = false
break
}
}
if allPrintable {
t.Fatalf("poolID first 8 bytes are all printable ascii (%q) — looks like a synthetic ticker id", p1[:8])
}
}
// asciiPoolID mirrors cmd/clobverify.poolIDForSymbol — the SYNTHETIC form dexseed must
// never produce.
func asciiPoolID(sym string) [32]byte {
var p [32]byte
copy(p[:], sym)
p[31] = 0xD0
return p
}
// TestRealMarketFrames proves the frozen frames are built correctly and bind the
// canonical real ids: the OpenMarket frame is exactly 96 bytes, its base/quote slots
// equal the derived ids, the Deposit frame is 88 bytes binding the base id, and the
// Place frame is a 65-byte resting SELL by the maker at the given price/size.
func TestRealMarketFrames(t *testing.T) {
cid := mustCChain(t)
const (
net uint32 = 3
maker = "maker"
askPrice = 1.25
askSize = 7.0
depAmt uint64 = 9
)
var depRef [32]byte
depRef[0] = 0xAB
open, dep, place, plan, err := realMarketFrames(net, cid, nativeSpec(), erc20Spec(erc20Addr(0x01)), maker, askPrice, askSize, depAmt, depRef)
if err != nil {
t.Fatalf("realMarketFrames: %v", err)
}
// The plan's ids must be the golden ids.
if got := hex.EncodeToString(plan.BaseID[:]); got != goldenNativeID3 {
t.Fatalf("plan baseID = %s, want golden native %s", got, goldenNativeID3)
}
if got := hex.EncodeToString(plan.QuoteID[:]); got != goldenERC20ID3 {
t.Fatalf("plan quoteID = %s, want golden erc20 %s", got, goldenERC20ID3)
}
// (a) OpenMarket is 96 bytes and its slots equal the derived ids.
if len(open) != zapwire.OpenMarketReqSize {
t.Fatalf("OpenMarket frame len = %d, want %d", len(open), zapwire.OpenMarketReqSize)
}
gotPool, gotBase, gotQuote, err := zapwire.DecodeOpenMarket(open)
if err != nil {
t.Fatalf("DecodeOpenMarket: %v", err)
}
if gotPool != plan.PoolID {
t.Fatalf("OpenMarket poolID slot = %x, want %x", gotPool, plan.PoolID)
}
if hex.EncodeToString(gotBase[:]) != goldenNativeID3 {
t.Fatalf("OpenMarket base slot = %x, want golden native", gotBase)
}
if hex.EncodeToString(gotQuote[:]) != goldenERC20ID3 {
t.Fatalf("OpenMarket quote slot = %x, want golden erc20", gotQuote)
}
// (b) Deposit is 88 bytes, binds the maker + base id + amount + ref.
if len(dep) != zapwire.DepositReqSize {
t.Fatalf("Deposit frame len = %d, want %d", len(dep), zapwire.DepositReqSize)
}
gotUser, gotAsset, gotAmt, gotRef, err := zapwire.DecodeDeposit(dep)
if err != nil {
t.Fatalf("DecodeDeposit: %v", err)
}
if gotUser != maker {
t.Fatalf("Deposit user = %q, want %q", gotUser, maker)
}
if hex.EncodeToString(gotAsset[:]) != goldenNativeID3 {
t.Fatalf("Deposit asset = %x, want golden native (base)", gotAsset)
}
if gotAmt != depAmt {
t.Fatalf("Deposit amount = %d, want %d", gotAmt, depAmt)
}
if gotRef != depRef {
t.Fatalf("Deposit ref = %x, want %x", gotRef, depRef)
}
// (c) Place is 65 bytes, a resting SELL by the maker at askPrice x askSize on poolID.
if len(place) != zapwire.PlaceReqSize {
t.Fatalf("Place frame len = %d, want %d", len(place), zapwire.PlaceReqSize)
}
gotPP, gotSide, gotPrice, gotSize, gotPlaceUser, err := zapwire.DecodePlace(place)
if err != nil {
t.Fatalf("DecodePlace: %v", err)
}
if gotPP != plan.PoolID {
t.Fatalf("Place poolID = %x, want %x", gotPP, plan.PoolID)
}
if gotSide != zapwire.SideSell {
t.Fatalf("Place side = %d, want SELL(%d)", gotSide, zapwire.SideSell)
}
if gotPrice != askPrice || gotSize != askSize {
t.Fatalf("Place price/size = %v/%v, want %v/%v", gotPrice, gotSize, askPrice, askSize)
}
if gotPlaceUser != maker {
t.Fatalf("Place user = %q, want %q", gotPlaceUser, maker)
}
}
// TestRealMarketFrames_RejectsSyntheticQuote proves the whole frame-build refuses a
// synthetic quote ref — no frames are returned for a market the chain would refuse.
func TestRealMarketFrames_RejectsSyntheticQuote(t *testing.T) {
cid := mustCChain(t)
syntheticQuote := assetSpec{Kind: dexcore.AssetKindERC20, Ref: []byte("LUSD")} // ascii ticker, not 20 bytes
open, dep, place, _, err := realMarketFrames(3, cid, nativeSpec(), syntheticQuote, "maker", 1.0, 1.0, 3, [32]byte{})
if err == nil {
t.Fatal("realMarketFrames MUST reject a synthetic (ascii-ticker) quote ref")
}
if open != nil || dep != nil || place != nil {
t.Fatal("no frames may be returned when asset admission fails")
}
}
// TestRealMarketFrames_RejectsSameBaseQuote proves a market whose base and quote
// resolve to the SAME id is refused (it is not a market).
func TestRealMarketFrames_RejectsSameBaseQuote(t *testing.T) {
cid := mustCChain(t)
same := erc20Spec(erc20Addr(0x07))
if _, _, _, _, err := realMarketFrames(3, cid, same, same, "maker", 1.0, 1.0, 3, [32]byte{}); err == nil {
t.Fatal("realMarketFrames MUST reject base==quote")
}
}
// TestDepositRefDeterministic proves the deposit idempotency ref is deterministic in
// (poolID, asset, amount) — a byte-identical re-seed dedups to exactly-once on the
// content-addressed D-Chain.
func TestDepositRefDeterministic(t *testing.T) {
cid := mustCChain(t)
baseID, err := nativeSpec().resolveID(3, cid)
if err != nil {
t.Fatalf("resolveID: %v", err)
}
pid := poolIDFor(baseID, baseID) // any pid for the test
r1 := depositRefFor(pid, baseID, 100)
r2 := depositRefFor(pid, baseID, 100)
if r1 != r2 {
t.Fatalf("depositRefFor not deterministic: %x != %x", r1, r2)
}
if r3 := depositRefFor(pid, baseID, 101); r3 == r1 {
t.Fatal("depositRefFor must change with amount")
}
}
// TestParseERC20Addr exercises the boundary parser: it accepts a 0x-prefixed and a bare
// 20-byte address, and rejects wrong-length / non-hex.
func TestParseERC20Addr(t *testing.T) {
ok := []string{
"0x0000000000000000000000000000000000000001",
"0000000000000000000000000000000000000001",
}
for _, s := range ok {
b, err := parseERC20Addr(s)
if err != nil {
t.Fatalf("parseERC20Addr(%q) unexpected error: %v", s, err)
}
if len(b) != 20 {
t.Fatalf("parseERC20Addr(%q) len = %d, want 20", s, len(b))
}
}
bad := []string{"", "0x", "0x01", "0xZZ00000000000000000000000000000000000001",
"0x000000000000000000000000000000000000000001"} // 21 bytes
for _, s := range bad {
if _, err := parseERC20Addr(s); err == nil {
t.Fatalf("parseERC20Addr(%q) must error", s)
}
}
}
+188
View File
@@ -0,0 +1,188 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package main
import (
"crypto/sha256"
"fmt"
"github.com/luxfi/dex/pkg/dexcore"
"github.com/luxfi/dex/pkg/zapwire"
"github.com/luxfi/ids"
)
// seed.go is the PURE, testable core of the canonical real-market seeder. It holds
// no I/O: every function here turns inputs into bytes (frozen zapwire frames) or
// derives the canonical 32-byte identities the native D-Chain keys its ledger by.
// main.go is the thin transport that POSTs these frames over HTTP.
//
// THE IDENTITY RULE this file enforces (and the reason this seeder is SAFE against a
// shared cluster, unlike cmd/clobverify): every asset handle is the dexcore CANONICAL
// AssetID — DeriveAssetID(networkID, C-chainID, kind, canonicalRef) — the SAME
// consensus-native identity primitive the on-chain resolver computes. It is NEVER an
// ascii-of-symbol id (the synthetic form, e.g. 0x4c5558 = "LUX", that bricked the
// chain by binding a resting order to a market with no real asset). A synthetic /
// malformed ref is rejected HERE, at frame-build time, before a single byte is sent,
// by CanonicalRefFor inside DeriveAssetID.
// poolIDDomain is the domain-separation tag for the canonical poolID derivation. It
// is folded as the FIRST field so a poolID preimage can never collide with any other
// SHA-256 use, and so the result is structurally unrelated to an ascii ticker.
var poolIDDomain = []byte("lux:dex:dexseed:poolid:v1")
// assetSpec names a real on-chain asset by WHERE IT LIVES: its kind (EVM_NATIVE /
// ERC20 / UTXO) and its canonical on-chain reference (the native marker for
// EVM_NATIVE, the 20-byte token address for ERC20, the 32-byte source assetID for
// UTXO). It is the seeder's input unit; resolveID turns it into the canonical
// AssetID the chain keys by. It deliberately carries NO ticker/symbol — identity is
// the derived id, never a string.
type assetSpec struct {
Kind dexcore.AssetKind
Ref []byte
}
// nativeSpec is the canonical EVM_NATIVE (LUX) asset spec: the EVM zero-address
// native marker. Its derived id is the chain's own coin under the bound network.
func nativeSpec() assetSpec {
return assetSpec{Kind: dexcore.AssetKindEVMNative, Ref: dexcore.EVMNativeMarker}
}
// erc20Spec builds an ERC20 asset spec from a 20-byte token contract address. The
// address is validated (length, non-zero) when the spec is resolved, not here.
func erc20Spec(addr []byte) assetSpec {
ref := make([]byte, len(addr))
copy(ref, addr)
return assetSpec{Kind: dexcore.AssetKindERC20, Ref: ref}
}
// resolveID derives the canonical, consensus-native 32-byte AssetID for this spec
// under (networkID, cChainID). It is the SINGLE admission gate in the seeder: a
// synthetic/malformed ref (wrong length, zero ERC-20 address, non-marker native ref,
// invalid kind) is REFUSED here by dexcore.DeriveAssetID — so no frame is ever built,
// let alone sent, for an asset that is not a well-formed real on-chain identity. This
// is byte-identical to what the chain's resolver derives, so a market this seeder
// opens binds the SAME id the chain admits.
func (a assetSpec) resolveID(networkID uint32, cChainID ids.ID) (ids.ID, error) {
id, err := dexcore.DeriveAssetID(networkID, cChainID, a.Kind, a.Ref)
if err != nil {
return ids.Empty, fmt.Errorf("resolve %s asset id: %w", a.Kind, err)
}
return id, nil
}
// poolIDFor derives the canonical, deterministic, NON-ascii poolID for a (base,
// quote) market from the two REAL derived asset ids:
//
// poolID = SHA-256( len|domain ‖ len|baseID ‖ len|quoteID )
//
// It is reproducible (the same base/quote always yield the same poolID, so a re-seed
// is idempotent at the market level) and structurally cannot equal an ascii ticker
// (the domain tag dominates the preimage). It is keyed by the DERIVED ids, not the
// refs, so two markets over the same economic pair on the same network share one
// poolID by construction.
func poolIDFor(baseID, quoteID ids.ID) [32]byte {
h := sha256.New()
writeLenPrefixed(h, poolIDDomain)
writeLenPrefixed(h, baseID[:])
writeLenPrefixed(h, quoteID[:])
var pid [32]byte
copy(pid[:], h.Sum(nil))
return pid
}
// writeLenPrefixed writes an 8-byte big-endian length followed by b, so the fold is
// injective (no two distinct field tuples share a preimage) — the same discipline as
// dexcore's assetFolder.
func writeLenPrefixed(h interface{ Write([]byte) (int, error) }, b []byte) {
var lp [8]byte
lp[0] = byte(len(b) >> 56)
lp[1] = byte(len(b) >> 48)
lp[2] = byte(len(b) >> 40)
lp[3] = byte(len(b) >> 32)
lp[4] = byte(len(b) >> 24)
lp[5] = byte(len(b) >> 16)
lp[6] = byte(len(b) >> 8)
lp[7] = byte(len(b))
_, _ = h.Write(lp[:])
_, _ = h.Write(b)
}
// marketPlan is the fully-derived, validated description of one real market the
// seeder will create: the canonical poolID and both REAL asset ids. It is produced by
// realMarketFrames (which also returns the frozen frames) and is what main.go reads
// back the book/markets against.
type marketPlan struct {
PoolID [32]byte
BaseID ids.ID
QuoteID ids.ID
}
// realMarketFrames is the pure heart of the seeder. Given the network identity, the
// C-Chain id, the base/quote REAL asset specs, the maker's 16-byte CLOB user handle,
// and the resting ask price/size, it:
//
// 1. derives both canonical asset ids (REFUSING any synthetic/malformed ref);
// 2. derives the canonical NON-ascii poolID from those ids;
// 3. builds the three FROZEN zapwire frames that seed the market end-to-end:
// - OpenMarket(poolID, baseID, quoteID) — bind the real (base,quote);
// - Deposit(maker, baseID, depositAmount, ref) — fund the maker's base balance;
// - Place(poolID, SELL, askPrice, askSize, maker) — rest a maker limit ask.
//
// It returns the raw frames, the validated plan, and any derivation error. It does NO
// I/O — main.go posts the frames. depositAmount must cover the ask lock (>= askSize in
// base whole units); ref is the deposit idempotency reference (originating-op id).
func realMarketFrames(
networkID uint32,
cChainID ids.ID,
base, quote assetSpec,
maker string,
askPrice, askSize float64,
depositAmount uint64,
depositRef [32]byte,
) (openMarket, deposit, place []byte, plan marketPlan, err error) {
baseID, err := base.resolveID(networkID, cChainID)
if err != nil {
return nil, nil, nil, marketPlan{}, fmt.Errorf("base: %w", err)
}
quoteID, err := quote.resolveID(networkID, cChainID)
if err != nil {
return nil, nil, nil, marketPlan{}, fmt.Errorf("quote: %w", err)
}
if baseID == quoteID {
return nil, nil, nil, marketPlan{}, fmt.Errorf("base and quote resolve to the same asset id %s — not a market", baseID)
}
pid := poolIDFor(baseID, quoteID)
openMarket = zapwire.EncodeOpenMarket(pid, baseID, quoteID)
deposit = zapwire.EncodeDeposit(maker, baseID, depositAmount, depositRef)
place = zapwire.EncodePlace(pid, zapwire.SideSell, askPrice, askSize, maker)
return openMarket, deposit, place, marketPlan{PoolID: pid, BaseID: baseID, QuoteID: quoteID}, nil
}
// depositRefFor derives a deterministic idempotency reference for the maker's seed
// deposit, domain-separated and bound to (poolID, asset, amount). The native D-Chain
// content-addresses its dedup index on the full frame, so a byte-identical re-seed
// (same pool, same asset, same amount) dedups to exactly-once — a re-run does not
// double-credit. It folds the canonical asset id (32 bytes), not a symbol.
func depositRefFor(poolID [32]byte, assetID ids.ID, amount uint64) [32]byte {
h := sha256.New()
writeLenPrefixed(h, []byte("lux:dex:dexseed:depositref:v1"))
writeLenPrefixed(h, poolID[:])
writeLenPrefixed(h, assetID[:])
var amt [8]byte
amt[0] = byte(amount >> 56)
amt[1] = byte(amount >> 48)
amt[2] = byte(amount >> 40)
amt[3] = byte(amount >> 32)
amt[4] = byte(amount >> 24)
amt[5] = byte(amount >> 16)
amt[6] = byte(amount >> 8)
amt[7] = byte(amount)
writeLenPrefixed(h, amt[:])
var ref [32]byte
copy(ref[:], h.Sum(nil))
return ref
}