Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions cmd/constants/marketmaps/markets.go
Original file line number Diff line number Diff line change
Expand Up @@ -9818,6 +9818,35 @@ var (
}
}`

// PythMarketMap is used to initialize the Pyth market map. This only includes
// the markets that are supported by Pyth.
PythMarketMap mmtypes.MarketMap

// PythMarketMapJSON is the JSON representation of the Pyth MarketMap that can be used
// to initialize for a genesis state or used by the sidecar as a static market map.
PythMarketMapJSON = `
{
"markets": {
"WTI/USD": {
"ticker": {
"currency_pair": {
"Base": "WTI",
"Quote": "USD"
},
"decimals": 8,
"min_provider_count": 1,
"enabled": true
},
"provider_configs": [
{
"name": "pyth_api",
"off_chain_ticker": "2694"
}
]
}
}
}`

// ForexMarketMap is used to initialize the forex market map. This only includes
// forex markets quoted in usdt.
ForexMarketMap mmtypes.MarketMap
Expand Down Expand Up @@ -9919,6 +9948,7 @@ func init() {
unmarshalValidate("Polymarket", PolymarketMarketMapJSON, &PolymarketMarketMap),
unmarshalValidate("Forex", ForexMarketMapJSON, &ForexMarketMap),
unmarshalValidate("Stork", StorkMarketMapJSON, &StorkMarketMap),
unmarshalValidate("Pyth", PythMarketMapJSON, &PythMarketMap),
)
if err != nil {
panic(err)
Expand Down
8 changes: 8 additions & 0 deletions cmd/constants/providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
krakenapi "github.com/dydxprotocol/slinky/providers/apis/kraken"
"github.com/dydxprotocol/slinky/providers/apis/marketmap"
"github.com/dydxprotocol/slinky/providers/apis/polymarket"
"github.com/dydxprotocol/slinky/providers/apis/pyth"
"github.com/dydxprotocol/slinky/providers/apis/stork"
"github.com/dydxprotocol/slinky/providers/volatile"
binancews "github.com/dydxprotocol/slinky/providers/websockets/binance"
Expand Down Expand Up @@ -163,6 +164,13 @@ var (
Type: types.ConfigType,
},

// Pyth provider
{
Name: pyth.Name,
API: pyth.DefaultAPIConfig,
Type: types.ConfigType,
},

// Stork provider
{
Name: stork.Name,
Expand Down
182 changes: 182 additions & 0 deletions providers/apis/pyth/api_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
package pyth

import (
"encoding/json"
"fmt"
"io"
"math/big"
"net/http"
"strconv"
"strings"
"time"

providertypes "github.com/dydxprotocol/slinky/providers/types"

"github.com/dydxprotocol/slinky/oracle/config"
"github.com/dydxprotocol/slinky/oracle/types"
)

var _ types.PriceAPIDataHandler = (*APIHandler)(nil)

// APIHandler implements the PriceAPIDataHandler interface for Pyth.
type APIHandler struct {
api config.APIConfig
cache types.ProviderTickers
}

// NewAPIHandler returns a new Pyth PriceAPIDataHandler.
func NewAPIHandler(
api config.APIConfig,
) (types.PriceAPIDataHandler, error) {
if api.Name != Name {
return nil, fmt.Errorf("expected api config name %s, got %s", Name, api.Name)
}

if !api.Enabled {
return nil, fmt.Errorf("api config for %s is not enabled", Name)
}

if err := api.ValidateBasic(); err != nil {
return nil, fmt.Errorf("invalid api config for %s: %w", Name, err)
}

return &APIHandler{
api: api,
cache: types.NewProviderTickers(),
}, nil
}

// CreateURL returns the URL used to fetch prices from the Pyth oracle service.
// Feed IDs are passed as a comma-separated "asset" query parameter, with
// "&provider=pyth" appended.
func (h *APIHandler) CreateURL(
tickers []types.ProviderTicker,
) (string, error) {
if len(tickers) == 0 {
return "", fmt.Errorf("no tickers provided")
}

ids := make([]string, len(tickers))
for i, ticker := range tickers {
ids[i] = ticker.GetOffChainTicker()
h.cache.Add(ticker)
}

return fmt.Sprintf(
"%s?asset=%s&provider=pyth",
h.api.Endpoints[0].URL,
strings.Join(ids, ","),
), nil
}

// ParseResponse parses a batch Pyth API response ({"data": [...]}), verifies
// each entry's Pyth Solana ed25519 signature, and returns the parsed prices.
//
// If the signed payload contains both price mantissa and exponent, the price is
// computed directly from signed data (mantissa * 10^exponent). If the payload
// only contains price (no exponent), the signature and feed ID are still
// verified, but the JSON price field is used as a fallback.
func (h *APIHandler) ParseResponse(
tickers []types.ProviderTicker,
resp *http.Response,
) types.PriceResponse {
body, err := io.ReadAll(resp.Body)
if err != nil {
return types.NewPriceResponseWithErr(
tickers,
providertypes.NewErrorWithCode(
fmt.Errorf("failed to read pyth response body: %w", err),
providertypes.ErrorFailedToDecode,
),
)
}

var batch BatchPriceResponse
if err := json.Unmarshal(body, &batch); err != nil {
return types.NewPriceResponseWithErr(
tickers,
providertypes.NewErrorWithCode(
fmt.Errorf("failed to decode pyth response: %w", err),
providertypes.ErrorFailedToDecode,
),
)
}

byMarket := make(map[string]PriceResponse, len(batch.Data))
for _, item := range batch.Data {
byMarket[item.Market] = item
}

var (
resolved = make(types.ResolvedPrices)
unresolved = make(types.UnResolvedPrices)
)

for _, ticker := range tickers {
offChain := ticker.GetOffChainTicker()

item, ok := byMarket[offChain]
if !ok {
unresolved[ticker] = providertypes.UnresolvedResult{
ErrorWithCode: providertypes.NewErrorWithCode(
fmt.Errorf("no pyth response for feed %s", offChain),
providertypes.ErrorNoResponse,
),
}
continue
}

feedID, err := strconv.ParseUint(offChain, 10, 32)
if err != nil {
unresolved[ticker] = providertypes.UnresolvedResult{
ErrorWithCode: providertypes.NewErrorWithCode(
fmt.Errorf("invalid feed ID %q: %w", offChain, err),
providertypes.ErrorInvalidResponse,
),
}
continue
}

feed, err := VerifyAndExtractFeed(item.PythSolanaPayload, uint32(feedID))
if err != nil {
unresolved[ticker] = providertypes.UnresolvedResult{
ErrorWithCode: providertypes.NewErrorWithCode(
fmt.Errorf("pyth payload verification failed for feed %s: %w", offChain, err),
providertypes.ErrorInvalidResponse,
),
}
continue
}

var price *big.Float
if feed.HasPrice && feed.HasExponent {
price, err = feed.ComputePrice()
if err != nil {
unresolved[ticker] = providertypes.UnresolvedResult{
ErrorWithCode: providertypes.NewErrorWithCode(
fmt.Errorf("failed to compute price from signed payload for feed %s: %w", offChain, err),
providertypes.ErrorFailedToParsePrice,
),
}
continue
}
} else {
// Exponent not in the signed payload; fall back to JSON price.
// Signature and feed ID have already been verified above.
price, ok = new(big.Float).SetString(item.Price)
if !ok {
unresolved[ticker] = providertypes.UnresolvedResult{
ErrorWithCode: providertypes.NewErrorWithCode(
fmt.Errorf("failed to parse JSON price %q for feed %s", item.Price, offChain),
providertypes.ErrorFailedToParsePrice,
),
}
continue
}
}

resolved[ticker] = types.NewPriceResult(price, time.Now().UTC())
}

return types.NewPriceResponse(resolved, unresolved)
}
Loading
Loading