Files
zeekayandHanzo Dev 45bff3f5b6 fix(dex): close price sources synchronously; build pkg/dpdk and pkg/orderbook on linux
`go test ./...` failed three ways on the runner. Two were build failures that
cannot happen on a mac, and one was a race that only loses under load.

pkg/dpdk — `undefined: syscall.SO_NOSIGPIPE`. DarwinOptimizations guarded itself
with `if runtime.GOOS != "darwin" { return }`, but SO_NOSIGPIPE is a COMPILE-time
constant that exists only on Darwin; a runtime check cannot save a reference the
linux build still has to resolve. Split into _darwin.go / _nodarwin.go. Verified
by cross-compiling both ways -- it failed for GOOS=linux before, both pass now.

pkg/orderbook — the cgo preamble uses uint64_t while including only <stdlib.h>.
macOS pulls stdint.h in transitively and glibc does not, so this built here and
never on the runner. Added the include.

pkg/price — TestXChainSourceStalePrice: "Expected price to be stale".

Not a flaky test; Close() was not a barrier. It closed the done channel and
returned WITHOUT waiting for the polling goroutine. A loop selecting on done
against a ticker picks a ready case AT RANDOM, so a poll could still be running
after Close() returned, and poll writes last[symbol] = time.Now(). The test
closes the source, backdates that timestamp by 10s, and asserts staleness -- an
in-flight poll overwrote it. Wide window on a loaded runner, near-zero on a
laptop, which is why it reproduces 0/30 here.

All EIGHT sources had it, so the fix is one shared `lifecycle` (embedded, so
`s.done` still resolves) whose stop() joins before returning and is safe to call
twice. Oracle had the same defect written as

    // Give time for goroutines to exit
    time.Sleep(100 * time.Millisecond)

which could not work either -- calcAverages blocks on a 1s ticker, so it ran up
to a full second after Stop() returned. Now on the same lifecycle, and its loops
select on done instead of polling a flag, so they leave promptly rather than
after one more tick. Nothing restarts a stopped source (checked: 0 tests), and
the other sources already could not.

    go build ./...                        OK
    GOOS=linux go build ./...             OK   (failed before)
    go vet price/dpdk/orderbook           OK
    go test ./pkg/price/ -race            ok, 3/3 runs
    go test -run StalePrice -count=30     ok

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-02 03:19:43 -07:00

298 lines
5.8 KiB
Go

package price
import (
"encoding/json"
"fmt"
"math"
"net/http"
"sync"
"time"
"github.com/gorilla/websocket"
)
// PythSource provides real-time prices from Pyth Network.
// Sub-second updates via WebSocket for low-latency trading.
type PythSource struct {
wsURL string
apiURL string
conn *websocket.Conn
client *http.Client
priceIDs map[string]string
prices map[string]*Data
subs map[string]bool
healthy bool
heartbeat time.Time
mu sync.RWMutex
lifecycle
}
// NewPythSource creates a Pyth Network price source.
func NewPythSource(wsURL, apiURL string) *PythSource {
return &PythSource{
wsURL: wsURL,
apiURL: apiURL,
client: &http.Client{Timeout: 5 * time.Second},
priceIDs: pythPriceIDs(),
prices: make(map[string]*Data),
subs: make(map[string]bool),
lifecycle: newLifecycle(),
}
}
func pythPriceIDs() map[string]string {
return map[string]string{
"BTC-USD": "0xe62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43",
"ETH-USD": "0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace",
"SOL-USD": "0xef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d",
"AVAX-USD": "0x93da3352f9f1d105fdfe4971cfa80e9dd777bfc5d0f683ebb6e1294b92137bb7",
"LUX-USD": "0x0000000000000000000000000000000000000000000000000000000000000001", // Placeholder
}
}
// Connect establishes WebSocket connection.
func (s *PythSource) Connect() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.conn != nil {
s.conn.Close()
}
dialer := websocket.Dialer{HandshakeTimeout: 10 * time.Second}
conn, _, err := dialer.Dial(s.wsURL, nil)
if err != nil {
return err
}
s.conn = conn
s.healthy = true
s.heartbeat = time.Now()
s.run(s.read)
s.run(s.ping)
// Subscribe to all price feeds
for symbol := range s.priceIDs {
s.subscribe(symbol)
}
return nil
}
func (s *PythSource) read() {
defer func() {
s.mu.Lock()
s.healthy = false
s.mu.Unlock()
}()
for {
select {
case <-s.done:
return
default:
var msg map[string]interface{}
if err := s.conn.ReadJSON(&msg); err != nil {
return
}
s.handle(msg)
}
}
}
func (s *PythSource) handle(msg map[string]interface{}) {
msgType, _ := msg["type"].(string)
switch msgType {
case "price_update":
s.handlePrice(msg)
case "heartbeat":
s.mu.Lock()
s.heartbeat = time.Now()
s.mu.Unlock()
}
}
func (s *PythSource) handlePrice(msg map[string]interface{}) {
data, ok := msg["data"].(map[string]interface{})
if !ok {
return
}
priceID, _ := data["price_id"].(string)
// Find symbol
var symbol string
for sym, id := range s.priceIDs {
if id == priceID {
symbol = sym
break
}
}
if symbol == "" {
return
}
price, _ := data["price"].(float64)
conf, _ := data["confidence"].(float64)
ts, _ := data["publish_time"].(float64)
if expo, ok := data["expo"].(float64); ok {
price = price * math.Pow(10, expo)
}
s.mu.Lock()
s.prices[symbol] = &Data{
Symbol: symbol,
Price: price,
Confidence: 1.0 - (conf / price),
Timestamp: time.Unix(int64(ts), 0),
Source: "pyth",
}
s.mu.Unlock()
}
func (s *PythSource) subscribe(symbol string) error {
id, ok := s.priceIDs[symbol]
if !ok {
return fmt.Errorf("unknown symbol: %s", symbol)
}
if s.conn == nil {
return nil
}
return s.conn.WriteJSON(map[string]interface{}{
"type": "subscribe",
"ids": []string{id},
})
}
func (s *PythSource) ping() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-s.done:
return
case <-ticker.C:
s.mu.RLock()
conn := s.conn
s.mu.RUnlock()
if conn != nil {
conn.WriteJSON(map[string]string{"type": "heartbeat"})
}
}
}
}
// Price returns the latest price for a symbol.
func (s *PythSource) Price(symbol string) (*Data, error) {
s.mu.RLock()
defer s.mu.RUnlock()
p, ok := s.prices[symbol]
if !ok {
return s.fetchHTTP(symbol)
}
copy := *p
return &copy, nil
}
func (s *PythSource) fetchHTTP(symbol string) (*Data, error) {
id, ok := s.priceIDs[symbol]
if !ok {
return nil, ErrNotFound
}
url := fmt.Sprintf("%s/api/latest_price_feeds?ids[]=%s", s.apiURL, id)
resp, err := s.client.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result []struct {
Price float64 `json:"price"`
Confidence float64 `json:"conf"`
Expo int32 `json:"expo"`
Time int64 `json:"publish_time"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
if len(result) == 0 {
return nil, ErrNotFound
}
r := result[0]
price := r.Price * math.Pow(10, float64(r.Expo))
return &Data{
Symbol: symbol,
Price: price,
Confidence: 1.0 - (r.Confidence / price),
Timestamp: time.Unix(r.Time, 0),
Source: "pyth",
}, nil
}
// Prices returns prices for multiple symbols.
func (s *PythSource) Prices(symbols []string) (map[string]*Data, error) {
result := make(map[string]*Data)
for _, sym := range symbols {
if p, err := s.Price(sym); err == nil {
result[sym] = p
}
}
return result, nil
}
// Subscribe registers for updates.
func (s *PythSource) Subscribe(symbol string) error {
s.mu.Lock()
s.subs[symbol] = true
s.mu.Unlock()
return s.subscribe(symbol)
}
// Unsubscribe removes subscription.
func (s *PythSource) Unsubscribe(symbol string) error {
s.mu.Lock()
delete(s.subs, symbol)
s.mu.Unlock()
return nil
}
// Healthy returns true if connected.
func (s *PythSource) Healthy() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.healthy && time.Since(s.heartbeat) < 30*time.Second
}
// Name returns "pyth".
func (s *PythSource) Name() string { return "pyth" }
// Weight returns 1.2 (external oracle).
func (s *PythSource) Weight() float64 { return 1.2 }
// Close shuts down the source.
func (s *PythSource) Close() error {
s.stop()
s.mu.Lock()
defer s.mu.Unlock()
if s.conn != nil {
return s.conn.Close()
}
return nil
}