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]>
783 lines
29 KiB
Go
783 lines
29 KiB
Go
package maker
|
||
|
||
import (
|
||
"context"
|
||
"encoding/binary"
|
||
"encoding/hex"
|
||
"errors"
|
||
"fmt"
|
||
"math"
|
||
"math/big"
|
||
"sync"
|
||
|
||
"github.com/luxfi/rpc"
|
||
)
|
||
|
||
// dex.go is the maker's client onto the VENUE D-Chain's CLOB ZAP surface. The
|
||
// venue (github.com/luxfi/dex/pkg/dchain, RegisterDEX) registers the FROZEN
|
||
// dex_* methods (ensure_market/place/cancel/submit) plus the CUSTODY methods
|
||
// (open_market/deposit/withdraw), all keyed by a 32-byte poolId / 32-byte asset
|
||
// id; it does NOT speak the legacy symbol-keyed place_order/best_bid surface.
|
||
//
|
||
// FROZEN WIRE FRAME: the constants and codecs below are the SAME frozen CLOB
|
||
// frame defined in github.com/luxfi/dex/pkg/zapwire, and re-defined byte-identically
|
||
// in chains/dexvm/relay.go and the precompile's engine_zap.go. Each client home
|
||
// re-declares the frame locally rather than importing the package (the canonical
|
||
// pattern across the DEX, so a public CGO-disabled build never drags in the GPU
|
||
// matching engine); the frame is FROZEN, so a payload built here is byte-identical
|
||
// to what the venue handler decodes. Sizes: poolId[32], asset[32], place=65,
|
||
// cancel=40, submit=66, ensure_market=32, open_market=96, deposit=88, ack=24,
|
||
// fill=17. dexpoolid_test.go and dexcustody_test.go assert byte-equality against
|
||
// github.com/luxfi/dex/pkg/{venue,zapwire} so a frame divergence fails the build.
|
||
//
|
||
// poolId model: the venue's market identity IS the poolId (its stored "symbol"
|
||
// is just the hex of the poolId — a display concern; see dchain block.go). The
|
||
// maker, as the gateway-level seeder, owns the human-symbol -> poolId mapping. It
|
||
// derives poolId by the SINGLE venue convention every other native client uses
|
||
// (pkg/venue.SymbolToPoolID, pkg/dchain.poolIDForSymbol, cmd/fourpath-live):
|
||
// ASCII(symbol) left-aligned into 32 bytes with byte[31]=0xD0. The same symbol
|
||
// therefore routes to the SAME on-chain book the FIX/WS/precompile rails address,
|
||
// across ensure/open/place/cancel/submit. A pair may still pin an explicit poolId
|
||
// via Pair.PoolID to seed an exact deployed V4 PoolKey hash (key.ID()).
|
||
|
||
// FROZEN dex_* ZAP method names. Byte-identical to dex/pkg/zapwire,
|
||
// chains/dexvm/relay.go, and precompile/dex/engine_zap.go.
|
||
const (
|
||
methodEnsureMarket = "dex_ensure_market"
|
||
methodPlace = "dex_place"
|
||
methodCancel = "dex_cancel"
|
||
methodSubmit = "dex_submit"
|
||
|
||
// CUSTODY methods (zapwire.Method{OpenMarket,Deposit}). open_market binds a
|
||
// market's (base,quote) asset ids so an order knows which asset it locks;
|
||
// deposit credits an account's available balance the book draws its locks
|
||
// from. Funding before placing is what makes the seeded books custody-backed.
|
||
methodOpenMarket = "dex_open_market"
|
||
methodDeposit = "dex_deposit"
|
||
)
|
||
|
||
// FROZEN frame sizes. All multi-byte integers are big-endian; all floats are
|
||
// IEEE-754 float64 in big-endian bit form.
|
||
const (
|
||
poolIDSize = 32 // V4 poolId width: keccak256(PoolKey)
|
||
userSize = 16 // on-wire user identity, null-padded
|
||
assetIDSize = 32 // full injective asset id (zapwire.AssetIDSize); NOT truncated
|
||
refSize = 32 // idempotency ref (zapwire.RefSize): originating-tx identity
|
||
|
||
ensureMarketReqSize = poolIDSize // 32
|
||
placeReqSize = poolIDSize + 1 + 8 + 8 + userSize // 65
|
||
cancelReqSize = poolIDSize + 8 // 40
|
||
submitReqSize = poolIDSize + 1 + 1 + 8 + 8 + userSize // 66
|
||
openMarketReqSize = poolIDSize + assetIDSize + assetIDSize // 96
|
||
depositReqSize = userSize + assetIDSize + 8 + refSize // 88
|
||
fillWireSize = 17 // price[8]+size[8]+takerSide[1]
|
||
balanceRespSize = 1 + 8 // status[1]+amount[8]
|
||
)
|
||
|
||
// FROZEN side bytes: 0=buy/bid, 1=sell/ask.
|
||
const (
|
||
sideBuy uint8 = 0
|
||
sideSell uint8 = 1
|
||
)
|
||
|
||
// FROZEN ack status bytes.
|
||
const (
|
||
statusPlaced uint8 = 0
|
||
statusCanceled uint8 = 1
|
||
statusRejected uint8 = 2
|
||
)
|
||
|
||
// Fill is one execution returned by dex_submit: price + size + the taker side.
|
||
type Fill struct {
|
||
Price float64
|
||
Size float64
|
||
TakerSide uint8
|
||
}
|
||
|
||
// ErrReadUnsupported is returned by BestBid/BestAsk: the venue's dex_* surface
|
||
// is write-only (ensure/place/cancel/submit). Book reads come from chain-state
|
||
// queries, not the ZAP order surface, so the seeder requotes around its
|
||
// configured reference mid rather than a live best-quote.
|
||
var ErrReadUnsupported = errors.New("maker: venue dex surface has no best-quote read; reads are chain-state queries")
|
||
|
||
// dexSide maps a maker order side string to the wire side byte.
|
||
func dexSide(side string) (uint8, error) {
|
||
switch side {
|
||
case "buy":
|
||
return sideBuy, nil
|
||
case "sell":
|
||
return sideSell, nil
|
||
default:
|
||
return 0, fmt.Errorf("invalid side %q", side)
|
||
}
|
||
}
|
||
|
||
// SymbolPoolID derives the deterministic 32-byte venue poolId for a human symbol
|
||
// by the SINGLE convention the whole native venue stack uses: the ASCII symbol
|
||
// bytes left-aligned into a 32-byte array, with byte[31] tagged 0xD0 so an
|
||
// all-symbol-prefix pool can never collide with the all-zero native-LUX asset id.
|
||
// It is byte-identical to github.com/luxfi/dex/pkg/venue.SymbolToPoolID (the
|
||
// FIX/WS resolver), pkg/dchain.poolIDForSymbol (the localnet/conservation
|
||
// harness), and cmd/fourpath-live's poolIDForSymbol — so the maker seeds the
|
||
// EXACT book the FIX, WS, and precompile rails address for the same symbol.
|
||
// dexpoolid_test.go asserts that byte-equality.
|
||
//
|
||
// This is the maker's default; a pair may still pin an explicit poolId via
|
||
// Pair.PoolID (e.g. a deployed V4 PoolKey hash key.ID()) through BindPool.
|
||
func SymbolPoolID(symbol string) [32]byte {
|
||
var id [32]byte
|
||
copy(id[:], symbol)
|
||
id[31] = 0xD0
|
||
return id
|
||
}
|
||
|
||
// --- frozen frame codecs (byte-identical to dex/pkg/zapwire) ---
|
||
|
||
func putFloat64(b []byte, f float64) {
|
||
binary.BigEndian.PutUint64(b, math.Float64bits(f))
|
||
}
|
||
|
||
func getFloat64(b []byte) float64 {
|
||
return math.Float64frombits(binary.BigEndian.Uint64(b))
|
||
}
|
||
|
||
func padNull(s string, size int) []byte {
|
||
out := make([]byte, size)
|
||
copy(out, s)
|
||
return out
|
||
}
|
||
|
||
func encodeEnsureMarket(pool [32]byte) []byte {
|
||
out := make([]byte, ensureMarketReqSize)
|
||
copy(out[0:poolIDSize], pool[:])
|
||
return out
|
||
}
|
||
|
||
func encodePlace(pool [32]byte, side uint8, price, size float64, user string) []byte {
|
||
out := make([]byte, placeReqSize)
|
||
copy(out[0:poolIDSize], pool[:])
|
||
out[poolIDSize] = side
|
||
putFloat64(out[poolIDSize+1:poolIDSize+9], price)
|
||
putFloat64(out[poolIDSize+9:poolIDSize+17], size)
|
||
copy(out[poolIDSize+17:], padNull(user, userSize))
|
||
return out
|
||
}
|
||
|
||
func encodeCancel(pool [32]byte, orderID uint64) []byte {
|
||
out := make([]byte, cancelReqSize)
|
||
copy(out[0:poolIDSize], pool[:])
|
||
binary.BigEndian.PutUint64(out[poolIDSize:poolIDSize+8], orderID)
|
||
return out
|
||
}
|
||
|
||
func encodeSubmit(pool [32]byte, side uint8, isMarket bool, limitPrice, size float64, user string) []byte {
|
||
out := make([]byte, submitReqSize)
|
||
copy(out[0:poolIDSize], pool[:])
|
||
out[poolIDSize] = side
|
||
if isMarket {
|
||
out[poolIDSize+1] = 1
|
||
}
|
||
putFloat64(out[poolIDSize+2:poolIDSize+10], limitPrice)
|
||
putFloat64(out[poolIDSize+10:poolIDSize+18], size)
|
||
copy(out[poolIDSize+18:], padNull(user, userSize))
|
||
return out
|
||
}
|
||
|
||
// encodeOpenMarket builds a dex_open_market payload: poolId[32] + baseAsset[32]
|
||
// + quoteAsset[32]. Byte-identical to zapwire.EncodeOpenMarket. It binds the
|
||
// market's asset identities so a later place/submit locks the SAME ledger row a
|
||
// deposit credited.
|
||
func encodeOpenMarket(pool, base, quote [32]byte) []byte {
|
||
out := make([]byte, openMarketReqSize)
|
||
copy(out[0:poolIDSize], pool[:])
|
||
copy(out[poolIDSize:poolIDSize+assetIDSize], base[:])
|
||
copy(out[poolIDSize+assetIDSize:], quote[:])
|
||
return out
|
||
}
|
||
|
||
// encodeDeposit builds a dex_deposit payload: user[16] + asset[32] + amount[8] +
|
||
// ref[32]. Byte-identical to zapwire.EncodeDeposit. asset is the FULL 32-byte
|
||
// injective id (never truncated — a truncated key could fold a worthless token
|
||
// onto the native-LUX vault). ref is the originating-op idempotency reference the
|
||
// venue folds into its content-addressed dedup key, so a byte-identical retry is
|
||
// exactly-once.
|
||
func encodeDeposit(user string, asset [32]byte, amount uint64, ref [32]byte) []byte {
|
||
out := make([]byte, depositReqSize)
|
||
copy(out[0:userSize], padNull(user, userSize))
|
||
copy(out[userSize:userSize+assetIDSize], asset[:])
|
||
binary.BigEndian.PutUint64(out[userSize+assetIDSize:userSize+assetIDSize+8], amount)
|
||
copy(out[userSize+assetIDSize+8:], ref[:])
|
||
return out
|
||
}
|
||
|
||
// txDeposit is the venue's TxDeposit type tag (pkg/dchain), mixed into the
|
||
// idempotency ref so a deposit ref can't alias a withdraw ref for the same
|
||
// (user, asset, amount). Kept == the venue's deposit tag.
|
||
const txDeposit byte = 1
|
||
|
||
// depositRef derives the deterministic 32-byte idempotency ref for a deposit from
|
||
// its (tag, user, asset, amount) content — byte-identical to the venue's
|
||
// contentRef convention (conservation_test.go / cmd/fourpath-live): copy the FULL
|
||
// asset id (its low-byte discriminant participates), XOR the type into byte 0 and
|
||
// the user bytes into [1:24), then XOR amount into the low 8 bytes. Byte-identical
|
||
// inputs yield the same ref (exactly-once retry); any difference yields a distinct
|
||
// ref (a genuinely distinct op).
|
||
func depositRef(user string, asset [32]byte, amount uint64) [32]byte {
|
||
var ref [32]byte
|
||
copy(ref[:], asset[:])
|
||
ref[0] ^= txDeposit
|
||
for i := 0; i < len(user) && i+1 < 24; i++ {
|
||
ref[1+i] ^= user[i]
|
||
}
|
||
binary.BigEndian.PutUint64(ref[24:32], binary.BigEndian.Uint64(asset[24:32])^amount)
|
||
return ref
|
||
}
|
||
|
||
// decodeBalanceResp reads (status, realizedAmount) from a deposit/withdraw
|
||
// response (status[1] + amount[8]). Byte-identical to zapwire.DecodeBalanceResp.
|
||
func decodeBalanceResp(resp []byte) (status uint8, amount uint64, err error) {
|
||
if len(resp) < balanceRespSize {
|
||
return 0, 0, fmt.Errorf("short balance resp: %d bytes", len(resp))
|
||
}
|
||
return resp[0], binary.BigEndian.Uint64(resp[1:9]), nil
|
||
}
|
||
|
||
// lockUnits returns the integer balance a place of (side, price, size) on a
|
||
// market with this base/quote locks, using the EXACT fixed-point math the venue
|
||
// applies in pkg/dchain.orderLock — so a deposit sized as the sum of an order's
|
||
// lockUnits is guaranteed to fund it (places are never rejected for insufficient
|
||
// funds). A SELL locks base = floor(size) units; a BUY locks quote =
|
||
// ceil(floor(size) * round(price*1e8) / 1e8). The venue truncates size to whole
|
||
// atomic units (sizeToUnits) and price to the PriceInt grid (priceToInt), so we
|
||
// mirror both truncations rather than rounding the float notional, which could
|
||
// under-fund by a unit.
|
||
func lockUnits(side uint8, price, size float64) uint64 {
|
||
if !(size > 0) {
|
||
return 0
|
||
}
|
||
units := uint64(size) // sizeToUnits: truncate toward zero
|
||
if units == 0 {
|
||
return 0
|
||
}
|
||
if side == sideSell {
|
||
return units // SELL locks base = size units
|
||
}
|
||
if !(price > 0) {
|
||
return 0
|
||
}
|
||
priceInt := uint64(price * PriceGridMul) // priceToInt: truncate to grid
|
||
if priceInt == 0 {
|
||
return 0
|
||
}
|
||
// ceil(units * priceInt / PriceGridMul) via big.Int (units*priceInt can
|
||
// exceed 64 bits before the divide), matching quoteUnitsCeil exactly.
|
||
q := new(big.Int).Mul(new(big.Int).SetUint64(units), new(big.Int).SetUint64(priceInt))
|
||
q.Add(q, big.NewInt(PriceGridMul-1))
|
||
q.Quo(q, big.NewInt(PriceGridMul))
|
||
if !q.IsUint64() {
|
||
return 0
|
||
}
|
||
return q.Uint64()
|
||
}
|
||
|
||
// decodeAck reads orderId and status from an ack. A reject frame
|
||
// (orderId[8]+status[1]+reasonLen[2]+reason) is accepted too: status is byte 8.
|
||
func decodeAck(resp []byte) (orderID uint64, status uint8, err error) {
|
||
if len(resp) < 9 {
|
||
return 0, 0, fmt.Errorf("short ack: %d bytes", len(resp))
|
||
}
|
||
return binary.BigEndian.Uint64(resp[0:8]), resp[8], nil
|
||
}
|
||
|
||
// decodeFills parses a dex_submit response: count[4] then count×fill. Every
|
||
// field is range-checked so a malformed frame is rejected, never trusted.
|
||
func decodeFills(resp []byte) ([]Fill, error) {
|
||
if len(resp) < 4 {
|
||
return nil, fmt.Errorf("fills response too short: %d", len(resp))
|
||
}
|
||
n := int(binary.BigEndian.Uint32(resp[0:4]))
|
||
if 4+n*fillWireSize > len(resp) {
|
||
return nil, fmt.Errorf("fills response truncated: count=%d len=%d", n, len(resp))
|
||
}
|
||
fills := make([]Fill, 0, n)
|
||
off := 4
|
||
for i := range n {
|
||
p := getFloat64(resp[off : off+8])
|
||
s := getFloat64(resp[off+8 : off+16])
|
||
side := resp[off+16]
|
||
off += fillWireSize
|
||
if !(p > 0) || math.IsInf(p, 0) {
|
||
return nil, fmt.Errorf("fill %d: invalid price %v", i, p)
|
||
}
|
||
if !(s > 0) || math.IsInf(s, 0) {
|
||
return nil, fmt.Errorf("fill %d: invalid size %v", i, s)
|
||
}
|
||
if side > 1 {
|
||
return nil, fmt.Errorf("fill %d: invalid side %d", i, side)
|
||
}
|
||
fills = append(fills, Fill{Price: p, Size: s, TakerSide: side})
|
||
}
|
||
return fills, nil
|
||
}
|
||
|
||
// orderRef remembers the poolId an order was placed into, so a later Cancel
|
||
// (whose interface carries only the engine order id) can build the poolId-keyed
|
||
// dex_cancel frame the venue requires.
|
||
type orderRef struct {
|
||
pool [32]byte
|
||
}
|
||
|
||
// DEXClient seeds liquidity into the venue D-Chain over the frozen CLOB ZAP
|
||
// surface. It dials the venue gateway and places/cancels resting limit orders
|
||
// keyed by poolId; the venue queues each as a tx and returns the
|
||
// consensus-decided ack/fills.
|
||
type DEXClient struct {
|
||
client rpc.Client
|
||
user string // <=16-byte maker identity written into each order
|
||
|
||
mu sync.Mutex
|
||
ensured map[[32]byte]bool // markets already ensure_market'd
|
||
orders map[uint64]orderRef // engine order id -> poolId (for cancel)
|
||
poolByID map[string][32]byte // symbol -> pinned poolId override
|
||
|
||
// signer authorizes money-moving ops (deposit/place/cancel/submit) by
|
||
// appending a signed envelope to the frozen body. nil keeps the legacy
|
||
// unsigned path (the in-process fake-server tests). nonce is the client's
|
||
// single monotonic per-account auth sequence, advanced under mu — one signed
|
||
// op at a time (the Seeder is sequential), strictly increasing, gap-free.
|
||
signer *Signer
|
||
nonce uint64
|
||
}
|
||
|
||
// DialDEX connects to a venue D-Chain CLOB ZAP endpoint (host:port).
|
||
func DialDEX(ctx context.Context, addr, user string) (*DEXClient, error) {
|
||
c, err := rpc.Dial(ctx, addr)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("dial dex %s: %w", addr, err)
|
||
}
|
||
return newClient(c, user), nil
|
||
}
|
||
|
||
// NewDEXClient wraps an already-dialed rpc.Client (used in tests with an
|
||
// in-process venue server).
|
||
func NewDEXClient(client rpc.Client, user string) *DEXClient {
|
||
return newClient(client, user)
|
||
}
|
||
|
||
func newClient(client rpc.Client, user string) *DEXClient {
|
||
return &DEXClient{
|
||
client: client,
|
||
user: user,
|
||
ensured: make(map[[32]byte]bool),
|
||
orders: make(map[uint64]orderRef),
|
||
poolByID: make(map[string][32]byte),
|
||
}
|
||
}
|
||
|
||
// UseSigner installs the auth signer for every money-moving op and rebinds the
|
||
// client's user to the signer's settlement account, so the frozen body's user[16]
|
||
// field (which encodePlace/encodeDeposit write from d.user) already matches the
|
||
// account the signature recovers to — the equality the venue's verifyTxSignature
|
||
// requires. Call it once at setup, before seeding. With no signer the client keeps
|
||
// its legacy unsigned behavior unchanged.
|
||
func (d *DEXClient) UseSigner(s *Signer) {
|
||
d.mu.Lock()
|
||
d.signer = s
|
||
d.user = s.WireUser()
|
||
d.mu.Unlock()
|
||
}
|
||
|
||
// nonceReader is the optional transport capability to read the venue's
|
||
// expected-next nonce for an account. The HTTP transport (dexhttp.go) satisfies
|
||
// it; the ZAP transport does not (it returns false at the type assertion).
|
||
type nonceReader interface {
|
||
GetNonce(ctx context.Context, accountHex string) (uint64, bool)
|
||
}
|
||
|
||
// SyncNonce resyncs the client's nonce to the venue's expected-next for the signer
|
||
// account, so the maker can RESTART/REDEPLOY without replaying — every money-moving
|
||
// tx must carry exactly the venue's next nonce, and a fresh process otherwise
|
||
// starts at 0 and is rejected once the account has transacted. No-op without a
|
||
// signer, or when the transport has no nonce read (pre-nonce-read venue → nonce
|
||
// stays 0, the prior behavior). Call once after UseSigner, before seeding.
|
||
func (d *DEXClient) SyncNonce(ctx context.Context) {
|
||
d.mu.Lock()
|
||
signer, user := d.signer, d.user
|
||
d.mu.Unlock()
|
||
if signer == nil {
|
||
return
|
||
}
|
||
nr, ok := d.client.(nonceReader)
|
||
if !ok {
|
||
return
|
||
}
|
||
if n, ok := nr.GetNonce(ctx, hex.EncodeToString([]byte(user))); ok {
|
||
d.mu.Lock()
|
||
d.nonce = n
|
||
d.mu.Unlock()
|
||
}
|
||
}
|
||
|
||
// RestingOrder is one row of the venue's resting book (dex_get_orders): the order
|
||
// id and the hex of the 16-byte account that placed it. Used by the startup
|
||
// reconcile to find and cancel the maker's own stale orders.
|
||
type RestingOrder struct {
|
||
OrderID uint64 `json:"orderId"`
|
||
User string `json:"user"`
|
||
}
|
||
|
||
// ordersReader is the optional transport capability to read a market's resting
|
||
// book. The HTTP transport (dexhttp.go) satisfies it; the ZAP transport does not.
|
||
type ordersReader interface {
|
||
GetOrders(ctx context.Context, poolHex string) ([]RestingOrder, bool)
|
||
}
|
||
|
||
// CancelRestingForSymbol cancels every resting order the signer account owns on the
|
||
// symbol's book — the startup RECONCILE so a restarted maker clears the orders it
|
||
// left on a prior run (its in-memory live set is empty after a restart) before
|
||
// reseeding, instead of leaking a stale book. No-op without a signer or an
|
||
// orders-reading transport (ZAP / a pre-read venue). Returns the number cancelled.
|
||
func (d *DEXClient) CancelRestingForSymbol(ctx context.Context, symbol string) (int, error) {
|
||
d.mu.Lock()
|
||
signer, user := d.signer, d.user
|
||
d.mu.Unlock()
|
||
if signer == nil {
|
||
return 0, nil
|
||
}
|
||
or, ok := d.client.(ordersReader)
|
||
if !ok {
|
||
return 0, nil
|
||
}
|
||
pool := d.poolID(symbol)
|
||
rows, ok := or.GetOrders(ctx, hex.EncodeToString(pool[:]))
|
||
if !ok {
|
||
return 0, nil
|
||
}
|
||
mine := hex.EncodeToString([]byte(user))
|
||
n := 0
|
||
for _, r := range rows {
|
||
if r.User != mine {
|
||
continue
|
||
}
|
||
payload := d.signPayload(txTypeCancel, encodeCancel(pool, r.OrderID))
|
||
resp, err := d.client.CallRaw(ctx, methodCancel, payload)
|
||
if err != nil {
|
||
return n, fmt.Errorf("reconcile cancel %d: %w", r.OrderID, err)
|
||
}
|
||
if _, status, derr := decodeAck(resp); derr != nil {
|
||
return n, derr
|
||
} else if status == statusRejected {
|
||
continue // already gone / not cancellable: skip, keep going
|
||
}
|
||
n++
|
||
}
|
||
return n, nil
|
||
}
|
||
|
||
// signPayload returns the wire payload for a money-moving op: the frozen body with
|
||
// the auth envelope appended when a signer is configured, consuming the next
|
||
// monotonic nonce under d.mu. Nonce assignment and signing happen under the lock
|
||
// so envelope N is fully built before N+1 is handed out — strictly monotonic,
|
||
// gap-free. With no signer it returns the body unchanged (the unsigned path the
|
||
// in-process fake-server tests rely on).
|
||
func (d *DEXClient) signPayload(typ byte, body []byte) []byte {
|
||
d.mu.Lock()
|
||
defer d.mu.Unlock()
|
||
if d.signer == nil {
|
||
return body
|
||
}
|
||
env := d.signer.envelope(typ, d.nonce, body)
|
||
d.nonce++
|
||
out := make([]byte, 0, len(body)+len(env))
|
||
out = append(out, body...)
|
||
return append(out, env...)
|
||
}
|
||
|
||
// BindPool pins an explicit poolId for a symbol, overriding the SymbolPoolID
|
||
// venue-convention default. Use it to seed the exact market a deployed EVM V4
|
||
// PoolKey resolves to (poolId = key.ID()), so the maker's liquidity lands in the
|
||
// same on-chain book the precompile routes swaps to.
|
||
func (d *DEXClient) BindPool(symbol string, poolID [32]byte) {
|
||
d.mu.Lock()
|
||
d.poolByID[symbol] = poolID
|
||
d.mu.Unlock()
|
||
}
|
||
|
||
// poolID returns the poolId for a symbol: the pinned override if present, else
|
||
// the venue-convention SymbolPoolID derivation.
|
||
func (d *DEXClient) poolID(symbol string) [32]byte {
|
||
d.mu.Lock()
|
||
pid, ok := d.poolByID[symbol]
|
||
d.mu.Unlock()
|
||
if ok {
|
||
return pid
|
||
}
|
||
return SymbolPoolID(symbol)
|
||
}
|
||
|
||
// Close releases the underlying connection.
|
||
func (d *DEXClient) Close() error {
|
||
if d.client == nil {
|
||
return nil
|
||
}
|
||
return d.client.Close()
|
||
}
|
||
|
||
// ensureMarket idempotently creates the venue market for a poolId. It calls
|
||
// dex_ensure_market at most once per poolId per client; the venue is itself
|
||
// idempotent on the market, so a re-call is harmless but skipped to save a
|
||
// round-trip.
|
||
func (d *DEXClient) ensureMarket(ctx context.Context, pool [32]byte) error {
|
||
d.mu.Lock()
|
||
done := d.ensured[pool]
|
||
d.mu.Unlock()
|
||
if done {
|
||
return nil
|
||
}
|
||
|
||
resp, err := d.client.CallRaw(ctx, methodEnsureMarket, encodeEnsureMarket(pool))
|
||
if err != nil {
|
||
return fmt.Errorf("dex_ensure_market: %w", err)
|
||
}
|
||
if _, status, derr := decodeAck(resp); derr != nil {
|
||
return fmt.Errorf("dex_ensure_market ack: %w", derr)
|
||
} else if status == statusRejected {
|
||
return fmt.Errorf("dex_ensure_market rejected")
|
||
}
|
||
|
||
d.mu.Lock()
|
||
d.ensured[pool] = true
|
||
d.mu.Unlock()
|
||
return nil
|
||
}
|
||
|
||
// EnsureMarket idempotently creates the venue market for a symbol. The seeder
|
||
// does not need to call it explicitly — PlaceLimit ensures the market lazily —
|
||
// but it is exported so a caller can pre-create markets.
|
||
func (d *DEXClient) EnsureMarket(ctx context.Context, symbol string) error {
|
||
return d.ensureMarket(ctx, d.poolID(symbol))
|
||
}
|
||
|
||
// PoolID returns the canonical venue poolId for a symbol (the pinned override if
|
||
// bound, else the venue-convention SymbolPoolID). It lets the seeder and tests
|
||
// address the SAME on-chain book the other native rails do.
|
||
func (d *DEXClient) PoolID(symbol string) [32]byte {
|
||
return d.poolID(symbol)
|
||
}
|
||
|
||
// OpenMarket binds the symbol's market to its (base, quote) asset ids over
|
||
// dex_open_market, ensuring the market exists first. It is the CUSTODY
|
||
// counterpart of EnsureMarket: ensure_market creates the book; open_market tells
|
||
// the book which asset each side spends, so a place locks (and a fill settles)
|
||
// the correct ledger row. Idempotent on the venue; safe to re-call.
|
||
func (d *DEXClient) OpenMarket(ctx context.Context, symbol string, base, quote [32]byte) error {
|
||
pool := d.poolID(symbol)
|
||
if err := d.ensureMarket(ctx, pool); err != nil {
|
||
return err
|
||
}
|
||
resp, err := d.client.CallRaw(ctx, methodOpenMarket, encodeOpenMarket(pool, base, quote))
|
||
if err != nil {
|
||
return fmt.Errorf("dex_open_market %s: %w", symbol, err)
|
||
}
|
||
if _, status, derr := decodeAck(resp); derr != nil {
|
||
return fmt.Errorf("dex_open_market %s ack: %w", symbol, derr)
|
||
} else if status == statusRejected {
|
||
return fmt.Errorf("dex_open_market %s rejected", symbol)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Deposit credits the maker's available balance of asset by amount over
|
||
// dex_deposit so resting orders have funds to lock. The idempotency ref is
|
||
// content-derived from (deposit-tag, user, asset, amount) — byte-identical to the
|
||
// venue's contentRef convention — so a re-seed (e.g. a maker restart re-running
|
||
// Seed with the same amounts) dedups to exactly-once on the venue rather than
|
||
// double-crediting. It verifies the venue credited the full amount.
|
||
func (d *DEXClient) Deposit(ctx context.Context, asset [32]byte, amount uint64) error {
|
||
if amount == 0 {
|
||
return nil
|
||
}
|
||
ref := depositRef(d.user, asset, amount)
|
||
payload := d.signPayload(txTypeDeposit, encodeDeposit(d.user, asset, amount, ref))
|
||
resp, err := d.client.CallRaw(ctx, methodDeposit, payload)
|
||
if err != nil {
|
||
return fmt.Errorf("dex_deposit: %w", err)
|
||
}
|
||
status, credited, derr := decodeBalanceResp(resp)
|
||
if derr != nil {
|
||
return fmt.Errorf("dex_deposit resp: %w", derr)
|
||
}
|
||
if status == statusRejected {
|
||
return fmt.Errorf("dex_deposit rejected")
|
||
}
|
||
if credited != amount {
|
||
return fmt.Errorf("dex_deposit credited %d, want %d", credited, amount)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// PlaceLimit rests a single limit order on the symbol's venue market and returns
|
||
// the consensus-assigned order id. It lazily ensures the market first. A rejected
|
||
// order returns an error (never a zero id with nil error).
|
||
func (d *DEXClient) PlaceLimit(ctx context.Context, symbol, side string, price, size float64) (uint64, error) {
|
||
sb, err := dexSide(side)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
if price <= 0 || size <= 0 {
|
||
return 0, fmt.Errorf("invalid price/size: %f/%f", price, size)
|
||
}
|
||
|
||
pool := d.poolID(symbol)
|
||
if err := d.ensureMarket(ctx, pool); err != nil {
|
||
return 0, err
|
||
}
|
||
|
||
payload := d.signPayload(txTypePlace, encodePlace(pool, sb, price, size, d.user))
|
||
resp, err := d.client.CallRaw(ctx, methodPlace, payload)
|
||
if err != nil {
|
||
return 0, fmt.Errorf("dex_place %s: %w", symbol, err)
|
||
}
|
||
orderID, status, err := decodeAck(resp)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
if status == statusRejected {
|
||
return 0, fmt.Errorf("dex_place %s: order rejected", symbol)
|
||
}
|
||
|
||
d.mu.Lock()
|
||
d.orders[orderID] = orderRef{pool: pool}
|
||
d.mu.Unlock()
|
||
return orderID, nil
|
||
}
|
||
|
||
// Cancel cancels a previously placed order by engine order id. The venue's
|
||
// dex_cancel frame is poolId-keyed, so the client looks up the poolId it
|
||
// recorded when the order was placed.
|
||
func (d *DEXClient) Cancel(ctx context.Context, orderID uint64) error {
|
||
d.mu.Lock()
|
||
ref, ok := d.orders[orderID]
|
||
d.mu.Unlock()
|
||
if !ok {
|
||
return fmt.Errorf("dex_cancel: unknown order id %d", orderID)
|
||
}
|
||
|
||
payload := d.signPayload(txTypeCancel, encodeCancel(ref.pool, orderID))
|
||
resp, err := d.client.CallRaw(ctx, methodCancel, payload)
|
||
if err != nil {
|
||
return fmt.Errorf("dex_cancel %d: %w", orderID, err)
|
||
}
|
||
_, status, err := decodeAck(resp)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if status == statusRejected {
|
||
return fmt.Errorf("dex_cancel %d: rejected", orderID)
|
||
}
|
||
|
||
d.mu.Lock()
|
||
delete(d.orders, orderID)
|
||
d.mu.Unlock()
|
||
return nil
|
||
}
|
||
|
||
// Submit crosses the symbol's book with a marketable order and returns the
|
||
// consensus-computed fills. isMarket=true sweeps unconditionally; otherwise it is
|
||
// an IOC limit bounded by limitPrice. The maker uses this to take liquidity or to
|
||
// prove a market crosses; the seeder itself only places.
|
||
func (d *DEXClient) Submit(ctx context.Context, symbol, side string, isMarket bool, limitPrice, size float64) ([]Fill, error) {
|
||
sb, err := dexSide(side)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if size <= 0 {
|
||
return nil, fmt.Errorf("invalid size: %f", size)
|
||
}
|
||
if !isMarket && limitPrice <= 0 {
|
||
return nil, fmt.Errorf("invalid limit price: %f", limitPrice)
|
||
}
|
||
|
||
pool := d.poolID(symbol)
|
||
if err := d.ensureMarket(ctx, pool); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
payload := d.signPayload(txTypeSubmit, encodeSubmit(pool, sb, isMarket, limitPrice, size, d.user))
|
||
resp, err := d.client.CallRaw(ctx, methodSubmit, payload)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("dex_submit %s: %w", symbol, err)
|
||
}
|
||
fills, err := decodeFills(resp)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("dex_submit %s: %w", symbol, err)
|
||
}
|
||
return fills, nil
|
||
}
|
||
|
||
// --- TEMPORARY DEBUG PROBES (fix/dex-seeder-native-custody) ---
|
||
// These expose the raw frozen frames so a -fillprobe run can prove, byte by byte,
|
||
// whether an ask rests (placed ack with a non-zero id) and exactly what bytes a
|
||
// crossing submit returns. They call the SAME codecs/transport the production path
|
||
// uses; nothing about the wire changes. Remove with the probe.
|
||
|
||
// PlaceRaw places a limit order and returns the order id, status, and the RAW ack
|
||
// bytes the venue returned, so the caller can dump them.
|
||
func (d *DEXClient) PlaceRaw(ctx context.Context, symbol, side string, price, size float64) (orderID uint64, status uint8, raw []byte, err error) {
|
||
sb, derr := dexSide(side)
|
||
if derr != nil {
|
||
return 0, 0, nil, derr
|
||
}
|
||
pool := d.poolID(symbol)
|
||
if err := d.ensureMarket(ctx, pool); err != nil {
|
||
return 0, 0, nil, err
|
||
}
|
||
raw, err = d.client.CallRaw(ctx, methodPlace, encodePlace(pool, sb, price, size, d.user))
|
||
if err != nil {
|
||
return 0, 0, raw, err
|
||
}
|
||
orderID, status, err = decodeAck(raw)
|
||
return orderID, status, raw, err
|
||
}
|
||
|
||
// SubmitRaw crosses the book and returns the decoded fills plus the RAW submit
|
||
// response bytes (count[4] then count×fill), so the caller can dump the frame even
|
||
// when fills is empty.
|
||
func (d *DEXClient) SubmitRaw(ctx context.Context, symbol, side string, isMarket bool, limitPrice, size float64) (fills []Fill, raw []byte, err error) {
|
||
sb, derr := dexSide(side)
|
||
if derr != nil {
|
||
return nil, nil, derr
|
||
}
|
||
pool := d.poolID(symbol)
|
||
if err := d.ensureMarket(ctx, pool); err != nil {
|
||
return nil, nil, err
|
||
}
|
||
raw, err = d.client.CallRaw(ctx, methodSubmit, encodeSubmit(pool, sb, isMarket, limitPrice, size, d.user))
|
||
if err != nil {
|
||
return nil, raw, err
|
||
}
|
||
fills, err = decodeFills(raw)
|
||
return fills, raw, err
|
||
}
|
||
|
||
// EncodedPlace returns the bytes a place frame would carry (for debug dumps).
|
||
func (d *DEXClient) EncodedPlace(symbol, side string, price, size float64) []byte {
|
||
sb, _ := dexSide(side)
|
||
return encodePlace(d.poolID(symbol), sb, price, size, d.user)
|
||
}
|
||
|
||
// LockUnits exposes the venue lock math for a (side, price, size) for debug.
|
||
func LockUnits(side uint8, price, size float64) uint64 { return lockUnits(side, price, size) }
|
||
|
||
// BestBid is not served by the venue's dex_* surface; see ErrReadUnsupported.
|
||
// The seeder's currentMid handles the error by requoting around its configured
|
||
// reference mid.
|
||
func (d *DEXClient) BestBid(ctx context.Context, symbol string) (price, size float64, err error) {
|
||
return 0, 0, ErrReadUnsupported
|
||
}
|
||
|
||
// BestAsk is not served by the venue's dex_* surface; see ErrReadUnsupported.
|
||
func (d *DEXClient) BestAsk(ctx context.Context, symbol string) (price, size float64, err error) {
|
||
return 0, 0, ErrReadUnsupported
|
||
}
|