Files
zeekayandHanzo Dev 1ac4c806b2 Go 1.27.1, and the code it can say more simply
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]>
2026-09-16 19:23:15 -07:00

783 lines
29 KiB
Go
Raw Permalink 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.
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
}