Files
dex/pkg/trading/handler.go

580 lines
16 KiB
Go

package trading
import (
"context"
"encoding/json"
"fmt"
"math/big"
"net/http"
"sort"
"strings"
"sync"
"time"
)
// handleQuote queries all venues and returns the best price.
func (api *TradingAPI) handleQuote(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
var req QuoteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
return
}
if err := api.validateQuoteRequest(req); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
quotes, routing := api.queryAllVenues(r.Context(), req)
if len(quotes) == 0 {
writeError(w, http.StatusNotFound, "no liquidity available for this pair")
return
}
sort.Slice(quotes, func(i, j int) bool {
ai, _ := new(big.Int).SetString(quotes[i].AmountOut, 10)
aj, _ := new(big.Int).SetString(quotes[j].AmountOut, 10)
if ai == nil {
return false
}
if aj == nil {
return true
}
return ai.Cmp(aj) > 0
})
best := quotes[0]
route := []Hop{{
TokenIn: req.TokenIn,
TokenOut: req.TokenOut,
Venue: best.Venue,
Fee: parseBPS(best.Fee),
AmountIn: req.Amount,
AmountOut: best.AmountOut,
}}
priceImpact := computePriceImpact(quotes)
execPrice := computeExecutionPrice(req.Amount, best.AmountOut)
resp := QuoteResponse{
Quote: &Quote{
AmountIn: req.Amount,
AmountOut: best.AmountOut,
Route: route,
Fee: best.Fee,
ExecutionPrice: execPrice,
},
Routing: routing,
Venues: quotes,
GasEstimate: best.GasEstimate,
PriceImpact: priceImpact,
PermitData: nil,
}
writeJSON(w, http.StatusOK, resp)
}
// handleSwap converts a quote into unsigned transaction calldata.
// Dispatches to the correct router based on the venue type in the route.
func (api *TradingAPI) handleSwap(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
var req SwapRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
return
}
if req.Quote == nil || len(req.Quote.Route) == 0 {
writeError(w, http.StatusBadRequest, "quote with at least one route hop is required")
return
}
quote := req.Quote
route := quote.Route
amountIn, ok := new(big.Int).SetString(quote.AmountIn, 10)
if !ok || amountIn.Sign() <= 0 {
writeError(w, http.StatusBadRequest, "invalid amountIn")
return
}
amountOut, ok := new(big.Int).SetString(quote.AmountOut, 10)
if !ok || amountOut.Sign() <= 0 {
writeError(w, http.StatusBadRequest, "invalid amountOut")
return
}
from := req.Swapper
if from == "" {
writeError(w, http.StatusBadRequest, "swapper address is required")
return
}
if !isValidAddress(from) {
writeError(w, http.StatusBadRequest, "invalid swapper address")
return
}
// Validate all addresses in the route.
for i, hop := range route {
if !isValidAddress(hop.TokenIn) || !isValidAddress(hop.TokenOut) {
writeError(w, http.StatusBadRequest, fmt.Sprintf("invalid token address in route hop %d", i))
return
}
}
// Dispatch calldata building based on venue type.
to, calldata, err := api.buildSwapCalldata(route, amountIn, amountOut)
if err != nil {
// Do not leak internal error details (router addresses, venue config) to client.
writeError(w, http.StatusInternalServerError, "failed to build swap transaction")
return
}
resp := SwapResponse{
Swap: TransactionRequest{
To: to,
From: from,
Data: calldata,
Value: "0",
ChainID: api.defaultChain,
GasLimit: "250000",
},
}
writeJSON(w, http.StatusOK, resp)
}
// buildSwapCalldata dispatches calldata building based on the venue type in the route.
func (api *TradingAPI) buildSwapCalldata(route []Hop, amountIn, amountOut *big.Int) (string, string, error) {
venue := route[0].Venue
// Check if venue is executable (can produce on-chain calldata).
if !api.isVenueExecutable(venue) {
return "", "", fmt.Errorf("venue %q is off-chain only — use /v1/trade/order for broker execution", venue)
}
switch {
case isV4Venue(venue):
return api.buildV4Calldata(route, amountIn, amountOut)
case isV2Venue(venue):
return api.buildV2Calldata(route, amountIn, amountOut)
case isV3Venue(venue):
return api.buildV3Calldata(route, amountIn, amountOut)
default:
// Default to V4 native for unknown on-chain venues.
return api.buildV4Calldata(route, amountIn, amountOut)
}
}
// isVenueExecutable checks whether the named venue can produce on-chain calldata.
// Looks up the venue in api.venues and delegates to Venue.IsExecutable().
// Unknown venues (not registered) default to executable (on-chain).
func (api *TradingAPI) isVenueExecutable(name string) bool {
for _, v := range api.venues {
if v.Name() == name {
return v.IsExecutable()
}
}
// Unknown venues default to executable (on-chain).
return true
}
func isV4Venue(name string) bool {
return name == VenueTypeV4Native || name == "v4_native_clob" || strings.HasPrefix(name, "lux_")
}
func isV2Venue(name string) bool {
switch name {
case VenueTypeUniswapV2, VenueTypeSushiswap, VenueTypePancakeV2, VenueTypeTraderJoe:
return true
}
return strings.HasSuffix(name, "_v2")
}
func isV3Venue(name string) bool {
switch name {
case VenueTypeUniswapV3, VenueTypePancakeV3:
return true
}
return strings.HasSuffix(name, "_v3")
}
// buildV4Calldata builds calldata for the V4 LXRouter (0x9012).
func (api *TradingAPI) buildV4Calldata(route []Hop, amountIn, amountOut *big.Int) (string, string, error) {
if len(route) == 1 {
hop := route[0]
calldata, err := BuildExactInputSingleCalldata(
hop.TokenIn, hop.TokenOut, amountIn, amountOut,
big.NewInt(0), hop.PoolID,
)
return LXRouterAddress, calldata, err
}
path := make([]string, 0, len(route)+1)
path = append(path, route[0].TokenIn)
for _, hop := range route {
path = append(path, hop.TokenOut)
}
calldata, err := BuildExactInputCalldata(path, amountIn, amountOut, big.NewInt(0))
return LXRouterAddress, calldata, err
}
// buildV2Calldata builds calldata for a Uniswap V2-compatible Router02.
func (api *TradingAPI) buildV2Calldata(route []Hop, amountIn, amountOut *big.Int) (string, string, error) {
venue := route[0].Venue
routerAddr, ok := api.venueRouters[venue]
if !ok {
return "", "", fmt.Errorf("no router address configured for V2 venue %q", venue)
}
path := make([]string, 0, len(route)+1)
path = append(path, route[0].TokenIn)
for _, hop := range route {
path = append(path, hop.TokenOut)
}
// Recipient is the swapper (route[0].TokenIn is actually the from address for V4,
// but for V2 we need the actual recipient). Use zero address as placeholder —
// the ATS fills in the real MPC wallet address before signing.
recipient := "0x0000000000000000000000000000000000000000"
calldata, err := BuildV2SwapCalldata(routerAddr, path, amountIn, amountOut, recipient, nil)
return routerAddr, calldata, err
}
// buildV3Calldata builds calldata for a Uniswap V3-compatible SwapRouter.
func (api *TradingAPI) buildV3Calldata(route []Hop, amountIn, amountOut *big.Int) (string, string, error) {
if len(route) != 1 {
return "", "", fmt.Errorf("V3 multi-hop not yet implemented — use single hop")
}
venue := route[0].Venue
routerAddr, ok := api.venueRouters[venue]
if !ok {
return "", "", fmt.Errorf("no router address configured for V3 venue %q", venue)
}
hop := route[0]
// Hop.Fee is in BPS. V3 fee = BPS * 100 (hundredths of bip). 30 BPS -> 3000.
fee := uint32(hop.Fee * 100)
if fee == 0 {
fee = 3000 // default V3 fee tier
}
recipient := "0x0000000000000000000000000000000000000000"
calldata, err := BuildV3SwapCalldata(routerAddr, hop.TokenIn, hop.TokenOut, fee, recipient, amountIn, amountOut, nil)
return routerAddr, calldata, err
}
// handleOrder submits an order for off-chain routing via Broker.
func (api *TradingAPI) handleOrder(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
var req OrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
return
}
if err := api.validateOrderRequest(req); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
routing := api.determineOrderRouting(req)
orderID := generateOrderID()
now := time.Now().UTC().Format(time.RFC3339)
order := &OrderStatusResponse{
OrderID: orderID, Status: OrderStatusPending,
FilledQty: "0", AvgPrice: "0",
CreatedAt: now, UpdatedAt: now, Venue: routing,
}
api.mu.Lock()
// Evict oldest orders if store exceeds 10k entries to prevent unbounded growth.
// Production should use a database; this bounds the in-memory store.
const maxOrders = 10_000
if len(api.orders) >= maxOrders {
// Delete the first key found — map iteration is random, which approximates
// random eviction. Good enough for a bounded in-memory store.
for k := range api.orders {
delete(api.orders, k)
break
}
}
api.orders[orderID] = order
api.mu.Unlock()
writeJSON(w, http.StatusCreated, OrderResponse{
OrderID: orderID, Status: OrderStatusPending,
CreatedAt: now, Route: routing,
})
}
// Stub — returns mock pending status. Production: query EVMClient for tx receipt.
func (api *TradingAPI) handleSwapStatus(w http.ResponseWriter, r *http.Request) {
txHash := r.PathValue("txHash")
if txHash == "" {
writeError(w, http.StatusBadRequest, "txHash is required")
return
}
if len(txHash) != 66 || !strings.HasPrefix(txHash, "0x") {
writeError(w, http.StatusBadRequest, "invalid transaction hash format")
return
}
// Validate hex characters after 0x prefix.
for _, c := range txHash[2:] {
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
writeError(w, http.StatusBadRequest, "invalid transaction hash: non-hex characters")
return
}
}
writeJSON(w, http.StatusOK, SwapStatusResponse{TxHash: txHash, Status: "pending"})
}
func (api *TradingAPI) handleOrderStatus(w http.ResponseWriter, r *http.Request) {
orderID := r.PathValue("orderId")
if orderID == "" {
writeError(w, http.StatusBadRequest, "orderId is required")
return
}
api.mu.RLock()
order, ok := api.orders[orderID]
api.mu.RUnlock()
if !ok {
writeError(w, http.StatusNotFound, "order not found")
return
}
writeJSON(w, http.StatusOK, order)
}
// Stub — always approves. Production: call ERC20.allowance(owner, permit2) via EVMClient.
func (api *TradingAPI) handleCheckApproval(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
var req ApprovalCheckRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
return
}
if !isValidAddress(req.TokenAddress) {
writeError(w, http.StatusBadRequest, "invalid token address")
return
}
if !isValidAddress(req.Owner) {
writeError(w, http.StatusBadRequest, "invalid owner address")
return
}
if !api.validChainIDs[req.ChainID] {
writeError(w, http.StatusBadRequest, "unsupported chainId")
return
}
writeJSON(w, http.StatusOK, ApprovalCheckResponse{
Approved: true, Allowance: "115792089237316195423570985008687907853269984665640564039457584007913129639935",
NeedsApproval: false,
})
}
func (api *TradingAPI) handleVenues(w http.ResponseWriter, r *http.Request) {
venues := make([]VenueInfo, len(api.venues))
for i, v := range api.venues {
vType := "off_chain"
if v.IsExecutable() {
vType = "on_chain"
}
venues[i] = VenueInfo{
Name: v.Name(), Status: "active",
Executable: v.IsExecutable(), Type: vType,
}
}
writeJSON(w, http.StatusOK, venues)
}
// --- Internal routing logic ---
func (api *TradingAPI) queryAllVenues(ctx context.Context, req QuoteRequest) ([]VenueQuote, string) {
type result struct {
quote *VenueQuote
err error
}
results := make([]result, len(api.venues))
var wg sync.WaitGroup
for i, v := range api.venues {
wg.Add(1)
go func(idx int, venue Venue) {
defer wg.Done()
if req.PreferredVenue != "" && venue.Name() != req.PreferredVenue {
return
}
q, err := venue.Quote(ctx, req)
results[idx] = result{quote: q, err: err}
}(i, v)
}
wg.Wait()
var quotes []VenueQuote
hasOnChain := false
for _, res := range results {
if res.err != nil || res.quote == nil {
continue
}
quotes = append(quotes, *res.quote)
if res.quote.Executable {
hasOnChain = true
}
}
routing := RoutingBroker
if hasOnChain && len(quotes) > 0 {
routing = RoutingV4Native
}
return quotes, routing
}
func (api *TradingAPI) determineOrderRouting(req OrderRequest) string {
for _, v := range api.venues {
if v.Name() == "v4_native" && v.IsExecutable() {
return "v4_native"
}
}
for _, v := range api.venues {
if !v.IsExecutable() {
return v.Name()
}
}
return "none"
}
// --- Validation ---
func (api *TradingAPI) validateQuoteRequest(req QuoteRequest) error {
if !isValidAddress(req.TokenIn) {
return fmt.Errorf("invalid tokenIn address")
}
if !isValidAddress(req.TokenOut) {
return fmt.Errorf("invalid tokenOut address")
}
if strings.EqualFold(req.TokenIn, req.TokenOut) {
return fmt.Errorf("tokenIn and tokenOut must be different")
}
if !isPositiveDecimal(req.Amount) {
return fmt.Errorf("amount must be a positive integer")
}
if req.Type != QuoteTypeExactInput && req.Type != QuoteTypeExactOutput {
return fmt.Errorf("type must be EXACT_INPUT or EXACT_OUTPUT")
}
if !api.validChainIDs[req.ChainID] {
return fmt.Errorf("unsupported chainId: %d", req.ChainID)
}
if !isValidAddress(req.Swapper) {
return fmt.Errorf("invalid swapper address")
}
if req.Slippage < 0 || req.Slippage > 50 {
return fmt.Errorf("slippageTolerance must be between 0 and 50")
}
return nil
}
func (api *TradingAPI) validateOrderRequest(req OrderRequest) error {
if !isValidAddress(req.TokenIn) {
return fmt.Errorf("invalid tokenIn address")
}
if !isValidAddress(req.TokenOut) {
return fmt.Errorf("invalid tokenOut address")
}
if strings.EqualFold(req.TokenIn, req.TokenOut) {
return fmt.Errorf("tokenIn and tokenOut must be different")
}
if !isPositiveDecimal(req.Amount) {
return fmt.Errorf("amount must be a positive integer")
}
if req.Type != QuoteTypeExactInput && req.Type != QuoteTypeExactOutput {
return fmt.Errorf("type must be EXACT_INPUT or EXACT_OUTPUT")
}
if req.Side != OrderSideBuy && req.Side != OrderSideSell {
return fmt.Errorf("side must be BUY or SELL")
}
if req.OrderType != OrderTypeMarket && req.OrderType != OrderTypeLimit {
return fmt.Errorf("orderType must be MARKET or LIMIT")
}
if req.OrderType == OrderTypeLimit && !isPositiveDecimal(req.Price) {
return fmt.Errorf("price is required for LIMIT orders")
}
if !isValidAddress(req.Swapper) {
return fmt.Errorf("invalid swapper address")
}
if !api.validChainIDs[req.ChainID] {
return fmt.Errorf("unsupported chainId: %d", req.ChainID)
}
return nil
}
// --- Helpers ---
func computePriceImpact(quotes []VenueQuote) string {
if len(quotes) < 2 {
return "0.00"
}
best, _ := new(big.Int).SetString(quotes[0].AmountOut, 10)
second, _ := new(big.Int).SetString(quotes[1].AmountOut, 10)
if best == nil || second == nil || best.Sign() == 0 {
return "0.00"
}
diff := new(big.Int).Sub(best, second)
diff.Mul(diff, big.NewInt(10000))
diff.Div(diff, best)
whole := diff.Int64() / 100
frac := diff.Int64() % 100
return fmt.Sprintf("%d.%02d", whole, frac)
}
func computeExecutionPrice(amountInStr, amountOutStr string) string {
amountIn, ok := new(big.Int).SetString(amountInStr, 10)
if !ok || amountIn.Sign() == 0 {
return "0"
}
amountOut, ok := new(big.Int).SetString(amountOutStr, 10)
if !ok {
return "0"
}
e18 := new(big.Int).Exp(big.NewInt(10), big.NewInt(18), nil)
price := new(big.Int).Mul(amountOut, e18)
price.Div(price, amountIn)
return price.String()
}
var (
orderCounter uint64
orderCounterMu sync.Mutex
)
func generateOrderID() string {
orderCounterMu.Lock()
orderCounter++
n := orderCounter
orderCounterMu.Unlock()
return fmt.Sprintf("ord_%d_%d", time.Now().UnixMilli(), n)
}
func parseBPS(s string) int {
n := 0
for _, c := range s {
if c >= '0' && c <= '9' {
n = n*10 + int(c-'0')
} else {
return 0
}
}
return n
}