mirror of
https://github.com/luxfi/dex.git
synced 2026-09-24 00:26:31 +00:00
luxd serves chain routes under /v1/chain, and the node, cli and sdk releases already ship it. The deployed gateways still answer the old prefix, so remote reads break until they are redeployed; the estate moves together rather than sitting half converted. Lines that record an observation - a probe, a status code, a past release - keep the prefix they were written against, as do URLs on hosts that are not ours. Avalanche endpoints move to /ext/bc, which is the path that host serves. Co-authored-by: Hanzo Dev <[email protected]>
596 lines
20 KiB
Go
596 lines
20 KiB
Go
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package dchain
|
|
|
|
import (
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/luxfi/dex/pkg/dex"
|
|
"github.com/luxfi/dex/pkg/zapwire"
|
|
)
|
|
|
|
// read.go is the D-Chain's READ surface: dex_get_* query endpoints that return
|
|
// COMMITTED chain state (resting orders, the trade log, markets, the book) as
|
|
// JSON. It is the orthogonal counterpart to handler.go's write surface:
|
|
//
|
|
// handler.go dex_place/submit/cancel/... -> mempool -> consensus -> Verify-match.
|
|
// FROZEN binary zapwire frames. Mutates state through a consensus round.
|
|
// read.go dex_get_trades/orders/markets/book
|
|
// JSON. Reads committed state directly under vm.mu. ZERO consensus
|
|
// impact — no mempool, no block, no waiter; a read can never change a
|
|
// fill or a balance.
|
|
//
|
|
// Why JSON, not the frozen binary frames: the read surface is NOT on the
|
|
// consensus-critical wire (no byte-parity requirement with the proxy/precompile/
|
|
// maker — those only WRITE). It feeds two consumers that want self-describing
|
|
// records: (1) the markets-display pipeline (the indexer/exchange-api reads the
|
|
// committed trade log to render markets — native fills are trade: rows, so this
|
|
// is the source the graph/exchange-api consumes), and (2) cross-validator
|
|
// verification — querying the SAME endpoint on every validator and diffing the
|
|
// JSON proves whether the accepted fill replicated identically across the set.
|
|
//
|
|
// AUTHORITATIVE SOURCE: trades and markets are read from durable state (vm.db) —
|
|
// the committed truth, identical on every node for the same accepted height.
|
|
// Resting orders + book depth are read from the in-RAM book (vm.books), which is
|
|
// a deterministic fold of the committed order:* rows (rebuilt at Initialize,
|
|
// advanced at Accept), so it equals the durable resting set. Every response
|
|
// carries the chain head (height, lastAccepted, root) so a cross-validator diff
|
|
// is anchored to an exact accepted block — two nodes at the same height with the
|
|
// same root MUST return identical trades.
|
|
|
|
// read method names. They live in the dex_ namespace beside the write methods
|
|
// but are pure reads. The dex_ prefix means the node mounts them under the same
|
|
// /v1/chain/<D>/dex/ route group as the writes (one chain surface).
|
|
const (
|
|
MethodGetTrades = "dex_get_trades"
|
|
MethodGetOrders = "dex_get_orders"
|
|
MethodGetMarkets = "dex_get_markets"
|
|
MethodGetBook = "dex_get_book"
|
|
MethodGetNonce = "dex_get_nonce"
|
|
)
|
|
|
|
// defaultTradeLimit bounds an unparameterized dex_get_trades response so a chain
|
|
// with a long fill history cannot return an unbounded body by default. A caller
|
|
// paging the full log passes an explicit limit + since cursor.
|
|
const defaultTradeLimit = 1000
|
|
|
|
// readMethod pairs a read method name with its http.Handler. Reads are GET (a
|
|
// query has no body and is safely cacheable/idempotent), distinct from the writes'
|
|
// POST. The table is the single source of the read surface, folded into
|
|
// httpHandlers alongside the write table.
|
|
type readMethod struct {
|
|
method string
|
|
handler http.HandlerFunc
|
|
}
|
|
|
|
// readMethods is the authoritative read method->handler table. A method added
|
|
// here is mounted under /v1/chain/<D>/dex/<method> automatically (httpHandlers).
|
|
func (vm *VM) readMethods() []readMethod {
|
|
return []readMethod{
|
|
{MethodGetTrades, vm.handleGetTrades},
|
|
{MethodGetOrders, vm.handleGetOrders},
|
|
{MethodGetMarkets, vm.handleGetMarkets},
|
|
{MethodGetBook, vm.handleGetBook},
|
|
{MethodGetNonce, vm.handleGetNonce},
|
|
}
|
|
}
|
|
|
|
// chainHead is the accepted-block anchor every read response carries. Two nodes
|
|
// reporting the same Height with the same Root have committed the identical
|
|
// history — so their Trades for that range MUST match byte-for-byte. This is the
|
|
// hook a cross-validator verifier compares first.
|
|
type chainHead struct {
|
|
Height uint64 `json:"height"`
|
|
LastAccepted string `json:"lastAccepted"`
|
|
Root string `json:"root"`
|
|
}
|
|
|
|
// headLocked reads the accepted-block anchor. The caller MUST hold vm.mu (every
|
|
// read handler takes it to snapshot state + head together, so the reported head
|
|
// is exactly the block the returned rows were read at). One place builds the head
|
|
// — the four handlers do not each re-implement it.
|
|
func (vm *VM) headLocked() chainHead {
|
|
return chainHead{
|
|
Height: vm.lastAcceptedHeight,
|
|
LastAccepted: vm.lastAcceptedID.String(),
|
|
Root: hex.EncodeToString(vm.lastRoot[:]),
|
|
}
|
|
}
|
|
|
|
// tradeJSON is one committed fill rendered for display/verification. Prices/sizes
|
|
// are decoded from the row's deterministic fixed-point form to human floats for
|
|
// the display feed; the integer truth stays on-chain. Coordinates (Height, Seq)
|
|
// pin the fill to its trade:<height><seq> row so paging and diffing are exact.
|
|
type tradeJSON struct {
|
|
Height uint64 `json:"height"`
|
|
Seq uint64 `json:"seq"`
|
|
TradeID uint64 `json:"tradeId"`
|
|
Price float64 `json:"price"`
|
|
Size float64 `json:"size"`
|
|
TakerSide string `json:"takerSide"` // "buy" | "sell"
|
|
MakerOrderID uint64 `json:"makerOrderId"`
|
|
TakerOrderID uint64 `json:"takerOrderId"`
|
|
Timestamp int64 `json:"timestamp"` // unix nanos
|
|
}
|
|
|
|
// getTradesResp is the dex_get_trades body: the chain head plus the committed
|
|
// fills for the requested range, in matcher production order (height then seq).
|
|
type getTradesResp struct {
|
|
chainHead
|
|
Count int `json:"count"`
|
|
Trades []tradeJSON `json:"trades"`
|
|
}
|
|
|
|
// handleGetTrades serves the committed trade log:
|
|
//
|
|
// GET .../dex/dex_get_trades?since=<height>&limit=<n>
|
|
//
|
|
// since (optional): first accepted height to include (inclusive); default 0.
|
|
// limit (optional): max rows; default defaultTradeLimit.
|
|
//
|
|
// SCOPE: the trade: log is GLOBAL and height-ordered — the 80-byte on-chain trade
|
|
// row carries no poolID (it is the frozen GPU ABI), so this returns every market's
|
|
// fills in commit order, not a per-market slice. That is exactly right for the two
|
|
// consumers: a single-market devnet (the cross-validator verification case) has one
|
|
// market so the global log IS that market's log; the markets-display indexer keys
|
|
// fills to a market by the maker/taker order ids it already tracks per market. A
|
|
// true server-side per-market filter would require a separate per-market trade
|
|
// index in consensus state — a deliberate state change, not a read concern.
|
|
//
|
|
// The fills are read from durable trade:* rows — the authoritative committed log,
|
|
// identical on every validator at the same height — so this is BOTH the markets
|
|
// feed and the cross-validator replication probe.
|
|
func (vm *VM) handleGetTrades(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeReadErr(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
q := r.URL.Query()
|
|
since, err := parseUintParam(q.Get("since"), 0)
|
|
if err != nil {
|
|
writeReadErr(w, http.StatusBadRequest, "bad since: "+err.Error())
|
|
return
|
|
}
|
|
limit, err := parseIntParam(q.Get("limit"), defaultTradeLimit)
|
|
if err != nil {
|
|
writeReadErr(w, http.StatusBadRequest, "bad limit: "+err.Error())
|
|
return
|
|
}
|
|
|
|
// Read committed trade rows under the VM lock so the snapshot is consistent
|
|
// with the head we report.
|
|
vm.mu.Lock()
|
|
rows, rerr := readTrades(vm.db, since, limit)
|
|
head := vm.headLocked()
|
|
vm.mu.Unlock()
|
|
if rerr != nil {
|
|
writeReadErr(w, http.StatusInternalServerError, rerr.Error())
|
|
return
|
|
}
|
|
|
|
resp := getTradesResp{chainHead: head, Trades: make([]tradeJSON, 0, len(rows))}
|
|
for _, ct := range rows {
|
|
resp.Trades = append(resp.Trades, toTradeJSON(ct))
|
|
}
|
|
resp.Count = len(resp.Trades)
|
|
writeJSON(w, resp)
|
|
}
|
|
|
|
// orderJSON is one resting order rendered for display. Price/size are the float
|
|
// projection of the order's committed value (the integer lane stays on-chain).
|
|
type orderJSON struct {
|
|
OrderID uint64 `json:"orderId"`
|
|
Side string `json:"side"` // "buy" | "sell"
|
|
Price float64 `json:"price"`
|
|
Size float64 `json:"size"`
|
|
Remaining float64 `json:"remaining"`
|
|
User string `json:"user"`
|
|
}
|
|
|
|
// getOrdersResp is the dex_get_orders body: the chain head plus the market's
|
|
// resting book rows.
|
|
type getOrdersResp struct {
|
|
chainHead
|
|
Market string `json:"market"`
|
|
Count int `json:"count"`
|
|
Orders []orderJSON `json:"orders"`
|
|
}
|
|
|
|
// handleGetOrders serves a market's resting orders:
|
|
//
|
|
// GET .../dex/dex_get_orders?market=<hex>
|
|
//
|
|
// The orders are read from the in-RAM book (a deterministic fold of the committed
|
|
// order:* rows), so a follower and the proposer at the same height return the same
|
|
// resting set. market is required (resting orders are per-market).
|
|
func (vm *VM) handleGetOrders(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeReadErr(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
pid, err := parsePoolID(r.URL.Query().Get("market"))
|
|
if err != nil {
|
|
writeReadErr(w, http.StatusBadRequest, "bad market: "+err.Error())
|
|
return
|
|
}
|
|
|
|
vm.mu.Lock()
|
|
ob := vm.books[pid]
|
|
head := vm.headLocked()
|
|
var orders []orderJSON
|
|
if ob != nil {
|
|
orders = make([]orderJSON, 0, len(ob.Orders))
|
|
for _, o := range ob.Orders {
|
|
if o == nil {
|
|
continue
|
|
}
|
|
orders = append(orders, orderJSON{
|
|
OrderID: o.ID,
|
|
Side: sideString(o.Side),
|
|
Price: o.Price,
|
|
Size: o.Size,
|
|
Remaining: o.RemainingSize,
|
|
// Hex-encode the 16-byte account: the raw settlement id is not valid
|
|
// UTF-8, so emitting it as a Go string is LOSSY (json replaces the
|
|
// invalid bytes with U+FFFD) — an owner read back from this field
|
|
// could never round-trip. Hex round-trips, so a client can match its
|
|
// own account (the maker's startup reconcile) and it stays the same
|
|
// encoding as dex_get_nonce's account field. One encoding for an
|
|
// account across the read surface.
|
|
User: hex.EncodeToString([]byte(o.User)),
|
|
})
|
|
}
|
|
}
|
|
vm.mu.Unlock()
|
|
|
|
sortOrdersJSON(orders)
|
|
writeJSON(w, getOrdersResp{
|
|
chainHead: head,
|
|
Market: hex.EncodeToString(pid[:]),
|
|
Count: len(orders),
|
|
Orders: orders,
|
|
})
|
|
}
|
|
|
|
// marketJSON is one market's display record: its poolID + symbol, whether its
|
|
// (base,quote) assets are bound (a custody market), and a one-glance book summary.
|
|
type marketJSON struct {
|
|
PoolID string `json:"poolId"`
|
|
Symbol string `json:"symbol"`
|
|
Base string `json:"base,omitempty"`
|
|
Quote string `json:"quote,omitempty"`
|
|
AssetsBound bool `json:"assetsBound"`
|
|
Orders int `json:"orders"`
|
|
Remaining float64 `json:"remaining"`
|
|
BestBid float64 `json:"bestBid"`
|
|
BestAsk float64 `json:"bestAsk"`
|
|
}
|
|
|
|
// getMarketsResp is the dex_get_markets body: the chain head plus every market.
|
|
type getMarketsResp struct {
|
|
chainHead
|
|
Count int `json:"count"`
|
|
Markets []marketJSON `json:"markets"`
|
|
}
|
|
|
|
// handleGetMarkets serves every market on the chain:
|
|
//
|
|
// GET .../dex/dex_get_markets
|
|
//
|
|
// Each record carries the market's durable identity (poolID, symbol, bound
|
|
// assets) and an in-RAM book summary (resting orders, best bid/ask). This is what
|
|
// the markets-display lists; combined with dex_get_trades it renders a venue.
|
|
func (vm *VM) handleGetMarkets(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeReadErr(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
|
|
vm.mu.Lock()
|
|
markets, lerr := listMarkets(vm.db)
|
|
if lerr != nil {
|
|
vm.mu.Unlock()
|
|
writeReadErr(w, http.StatusInternalServerError, lerr.Error())
|
|
return
|
|
}
|
|
head := vm.headLocked()
|
|
// Build display records under the lock so the book summary is consistent with
|
|
// the head. assetsBound/base/quote come from durable state; orders/bid/ask from
|
|
// the in-RAM book.
|
|
out := make([]marketJSON, 0, len(markets))
|
|
for _, m := range markets {
|
|
base, quote, bound, aerr := readMarketAssets(vm.db, m.PoolID)
|
|
if aerr != nil {
|
|
vm.mu.Unlock()
|
|
writeReadErr(w, http.StatusInternalServerError, aerr.Error())
|
|
return
|
|
}
|
|
rec := marketJSON{
|
|
PoolID: hex.EncodeToString(m.PoolID[:]),
|
|
Symbol: m.Symbol,
|
|
AssetsBound: bound,
|
|
}
|
|
if bound {
|
|
rec.Base = hex.EncodeToString(base[:])
|
|
rec.Quote = hex.EncodeToString(quote[:])
|
|
}
|
|
if ob := vm.books[m.PoolID]; ob != nil {
|
|
for _, o := range ob.Orders {
|
|
if o == nil {
|
|
continue
|
|
}
|
|
rec.Orders++
|
|
rec.Remaining += o.RemainingSize
|
|
}
|
|
if d := ob.GetDepth(1); d != nil {
|
|
if len(d.Bids) > 0 {
|
|
rec.BestBid = d.Bids[0].Price
|
|
}
|
|
if len(d.Asks) > 0 {
|
|
rec.BestAsk = d.Asks[0].Price
|
|
}
|
|
}
|
|
}
|
|
out = append(out, rec)
|
|
}
|
|
vm.mu.Unlock()
|
|
|
|
writeJSON(w, getMarketsResp{chainHead: head, Count: len(out), Markets: out})
|
|
}
|
|
|
|
// getNonceResp is the dex_get_nonce body: the chain head plus an account's
|
|
// EXPECTED-NEXT nonce. A signed client reads this at startup to resync its nonce
|
|
// stream to the venue (every money-moving tx must carry exactly this nonce), so it
|
|
// can restart/redeploy without replaying — the orthogonal counterpart to the
|
|
// monotonic gate in authorizeTx.
|
|
type getNonceResp struct {
|
|
chainHead
|
|
Account string `json:"account"`
|
|
Nonce uint64 `json:"nonce"`
|
|
}
|
|
|
|
// handleGetNonce serves an account's expected-next nonce:
|
|
//
|
|
// GET .../dex/dex_get_nonce?account=<hex of the 16-byte settlement account>
|
|
//
|
|
// Pure read of the durable nonce: row (state.go getNonce); absent == 0 (the
|
|
// account's first op). The account is the same 16-byte id the body's user[16]
|
|
// field carries (dchain Account16).
|
|
func (vm *VM) handleGetNonce(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeReadErr(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
b, err := hex.DecodeString(strings.TrimPrefix(r.URL.Query().Get("account"), "0x"))
|
|
if err != nil || len(b) != zapwire.UserSize {
|
|
writeReadErr(w, http.StatusBadRequest, fmt.Sprintf("account must be %d-byte hex", zapwire.UserSize))
|
|
return
|
|
}
|
|
var account userKey
|
|
copy(account[:], b)
|
|
|
|
vm.mu.Lock()
|
|
n, nerr := getNonce(vm.db, account)
|
|
head := vm.headLocked()
|
|
vm.mu.Unlock()
|
|
if nerr != nil {
|
|
writeReadErr(w, http.StatusInternalServerError, nerr.Error())
|
|
return
|
|
}
|
|
writeJSON(w, getNonceResp{chainHead: head, Account: hex.EncodeToString(account[:]), Nonce: n})
|
|
}
|
|
|
|
// levelJSON is one aggregated price level in the book.
|
|
type levelJSON struct {
|
|
Price float64 `json:"price"`
|
|
Size float64 `json:"size"`
|
|
Count int `json:"count"`
|
|
}
|
|
|
|
// getBookResp is the dex_get_book body: the chain head plus the market's
|
|
// aggregated bid/ask ladder and totals.
|
|
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"`
|
|
}
|
|
|
|
// handleGetBook serves a market's aggregated order book:
|
|
//
|
|
// GET .../dex/dex_get_book?market=<hex>&depth=<n>
|
|
//
|
|
// depth (optional): number of price levels per side; default 20. Levels and
|
|
// totals are read from the in-RAM book (the committed-row fold).
|
|
func (vm *VM) handleGetBook(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeReadErr(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
q := r.URL.Query()
|
|
pid, err := parsePoolID(q.Get("market"))
|
|
if err != nil {
|
|
writeReadErr(w, http.StatusBadRequest, "bad market: "+err.Error())
|
|
return
|
|
}
|
|
depth, err := parseIntParam(q.Get("depth"), 20)
|
|
if err != nil {
|
|
writeReadErr(w, http.StatusBadRequest, "bad depth: "+err.Error())
|
|
return
|
|
}
|
|
if depth <= 0 {
|
|
depth = 20
|
|
}
|
|
|
|
vm.mu.Lock()
|
|
ob := vm.books[pid]
|
|
head := vm.headLocked()
|
|
resp := getBookResp{
|
|
chainHead: head,
|
|
Market: hex.EncodeToString(pid[:]),
|
|
Bids: []levelJSON{},
|
|
Asks: []levelJSON{},
|
|
}
|
|
if ob != nil {
|
|
for _, o := range ob.Orders {
|
|
if o == nil {
|
|
continue
|
|
}
|
|
resp.Orders++
|
|
resp.Remaining += o.RemainingSize
|
|
}
|
|
if d := ob.GetDepth(depth); d != nil {
|
|
resp.Bids = levelsJSON(d.Bids)
|
|
resp.Asks = levelsJSON(d.Asks)
|
|
if len(d.Bids) > 0 {
|
|
resp.BestBid = d.Bids[0].Price
|
|
}
|
|
if len(d.Asks) > 0 {
|
|
resp.BestAsk = d.Asks[0].Price
|
|
}
|
|
}
|
|
}
|
|
vm.mu.Unlock()
|
|
|
|
writeJSON(w, resp)
|
|
}
|
|
|
|
// ---- small, pure helpers (no consensus, no state mutation) ----
|
|
|
|
// toTradeJSON projects a committed trade row to its display/verification form,
|
|
// decoding the deterministic fixed-point price/qty to floats and the side byte to
|
|
// a string. The on-chain row keeps the integer truth.
|
|
func toTradeJSON(ct committedTrade) tradeJSON {
|
|
t := ct.Trade
|
|
side := "buy"
|
|
if t.TakerSide == dex.DEXSideAsk {
|
|
side = "sell"
|
|
}
|
|
return tradeJSON{
|
|
Height: ct.Height,
|
|
Seq: ct.Seq,
|
|
TradeID: t.TradeID,
|
|
Price: t.Price.Float(),
|
|
Size: dex.FixedQtyToFloat(t.Quantity),
|
|
TakerSide: side,
|
|
MakerOrderID: t.MakerOrderID,
|
|
TakerOrderID: t.TakerOrderID,
|
|
Timestamp: int64(t.Timestamp),
|
|
}
|
|
}
|
|
|
|
// sortOrdersJSON imposes a stable display order on resting orders: bids by price
|
|
// descending, asks by price ascending, ties broken by orderID. The in-RAM book's
|
|
// Orders map has no inherent order, so without this two nodes could return the
|
|
// same set in different array positions — sorting makes a cross-node JSON diff
|
|
// position-stable as well as set-stable.
|
|
func sortOrdersJSON(orders []orderJSON) {
|
|
sort.Slice(orders, func(i, j int) bool {
|
|
a, b := orders[i], orders[j]
|
|
if a.Side != b.Side {
|
|
return a.Side == "buy" // buys first
|
|
}
|
|
if a.Price != b.Price {
|
|
if a.Side == "buy" {
|
|
return a.Price > b.Price
|
|
}
|
|
return a.Price < b.Price
|
|
}
|
|
return a.OrderID < b.OrderID
|
|
})
|
|
}
|
|
|
|
// sideString maps a book Side to "buy"/"sell".
|
|
func sideString(s dex.Side) string {
|
|
if s == dex.Sell {
|
|
return "sell"
|
|
}
|
|
return "buy"
|
|
}
|
|
|
|
// levelsJSON projects aggregated price levels to their display form.
|
|
func levelsJSON(levels []dex.PriceLevel) []levelJSON {
|
|
out := make([]levelJSON, 0, len(levels))
|
|
for _, l := range levels {
|
|
size := l.TotalSize
|
|
if size == 0 {
|
|
size = l.Size
|
|
}
|
|
count := l.OrderCount
|
|
if count == 0 {
|
|
count = l.Count
|
|
}
|
|
out = append(out, levelJSON{Price: l.Price, Size: size, Count: count})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// parsePoolID decodes a 32-byte poolID from hex (with or without 0x). It errors
|
|
// on a wrong length so a malformed market id can never alias a different market's
|
|
// keyspace.
|
|
func parsePoolID(s string) ([32]byte, error) {
|
|
var pid [32]byte
|
|
if s == "" {
|
|
return pid, fmt.Errorf("market required")
|
|
}
|
|
if len(s) >= 2 && (s[0:2] == "0x" || s[0:2] == "0X") {
|
|
s = s[2:]
|
|
}
|
|
b, err := hex.DecodeString(s)
|
|
if err != nil {
|
|
return pid, err
|
|
}
|
|
if len(b) != 32 {
|
|
return pid, fmt.Errorf("poolID must be 32 bytes, got %d", len(b))
|
|
}
|
|
copy(pid[:], b)
|
|
return pid, nil
|
|
}
|
|
|
|
// parseUintParam parses an optional uint64 query param, returning def when empty.
|
|
func parseUintParam(s string, def uint64) (uint64, error) {
|
|
if s == "" {
|
|
return def, nil
|
|
}
|
|
return strconv.ParseUint(s, 10, 64)
|
|
}
|
|
|
|
// parseIntParam parses an optional int query param, returning def when empty.
|
|
func parseIntParam(s string, def int) (int, error) {
|
|
if s == "" {
|
|
return def, nil
|
|
}
|
|
return strconv.Atoi(s)
|
|
}
|
|
|
|
// writeJSON writes v as a 200 JSON response. A marshal failure (should be
|
|
// impossible for these fixed-shape structs) is surfaced as a 500.
|
|
func writeJSON(w http.ResponseWriter, v interface{}) {
|
|
body, err := json.Marshal(v)
|
|
if err != nil {
|
|
writeReadErr(w, http.StatusInternalServerError, "marshal: "+err.Error())
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write(body)
|
|
}
|
|
|
|
// writeReadErr writes a JSON error body with the given status.
|
|
func writeReadErr(w http.ResponseWriter, status int, msg string) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_, _ = w.Write([]byte(`{"error":` + strconv.Quote(msg) + `}`))
|
|
}
|