Skip to content

Commit 9d2c096

Browse files
pp346Kefancao
andauthored
feat: stork integration (#22)
## Summary - Introduces a Stork PriceAPIDataHandler that queries /v1/prices/latest with comma-separated asset IDs. ## Details - Parses Stork’s integer price strings into floats and returns current UTC timestamps. - Registers the provider in APIQueryHandlerFactory. - API - Request: GET https://rest.jp.stork-oracle.network/v1/prices/latest?assets=<ID1,ID2,...> - Response: JSON map of asset_id -> price (string), scaled by 10^18. - Parsing: Converts price string to big.Float and divides by 1e18; unknown assets are ignored. - Timestamps: Uses time.Now().UTC() (provider timestamp not propagated). - Config - New provider name: stork_api. - DefaultAPIConfig: Enabled, Atomic=true, Timeout/Interval=3s, ReconnectTimeout=2s, MaxQueries=1. - Authentication: Authorization header populated from env var STORK_API_KEY. - Implementation - Caches requested tickers during CreateURL to map returned asset IDs back to ProviderTicker. - Error handling: - JSON decode failure → unresolved for all tickers (ErrorFailedToDecode). - Price parse failure → unresolved for that ticker (ErrorFailedToParsePrice). - Missing response for a requested ticker → unresolved (ErrorNoResponse). - Factory - Adds stork_api branch to instantiate the new API data handler. ## Risk & Impact - Low risk: additive provider; no changes to existing providers or schemas. - Operational: requires configuring STORK_API_KEY when enabling this provider. - Security: introduces API key usage via Authorization header. ## Testing - New unit tests: - URL creation: empty, single, multiple assets. - Response parsing: valid single/multiple, bad JSON, bad/empty price, missing/null data, no responses. - Numeric edges: hex literal, very small (1), very large, zero. - Verifies timestamps are set to “now” and unresolved errors are surfaced. --------- Co-authored-by: Kefan Cao <76069770+Kefancao@users.noreply.github.com> Co-authored-by: Kefan Cao <kefan@dydx.exchange>
1 parent 630d620 commit 9d2c096

8 files changed

Lines changed: 906 additions & 1 deletion

File tree

cmd/constants/marketmaps/markets.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9789,6 +9789,35 @@ var (
97899789
}
97909790
}`
97919791

9792+
// StorkMarketMap is used to initialize the Stork market map. This only includes
9793+
// the markets that are supported by Stork.
9794+
StorkMarketMap mmtypes.MarketMap
9795+
9796+
// StorkMarketMapJSON is the JSON representation of the Stork MarketMap that can be used
9797+
// to initialize for a genesis state or used by the sidecar as a static market map.
9798+
StorkMarketMapJSON = `
9799+
{
9800+
"markets": {
9801+
"XAU/USD": {
9802+
"ticker": {
9803+
"currency_pair": {
9804+
"Base": "XAU",
9805+
"Quote": "USD"
9806+
},
9807+
"decimals": 8,
9808+
"min_provider_count": 1,
9809+
"enabled": true
9810+
},
9811+
"provider_configs": [
9812+
{
9813+
"name": "stork_api",
9814+
"off_chain_ticker": "XAUUSD"
9815+
}
9816+
]
9817+
}
9818+
}
9819+
}`
9820+
97929821
// ForexMarketMap is used to initialize the forex market map. This only includes
97939822
// forex markets quoted in usdt.
97949823
ForexMarketMap mmtypes.MarketMap
@@ -9889,6 +9918,7 @@ func init() {
98899918
unmarshalValidate("Osmosis", OsmosisMarketMapJSON, &OsmosisMarketMap),
98909919
unmarshalValidate("Polymarket", PolymarketMarketMapJSON, &PolymarketMarketMap),
98919920
unmarshalValidate("Forex", ForexMarketMapJSON, &ForexMarketMap),
9921+
unmarshalValidate("Stork", StorkMarketMapJSON, &StorkMarketMap),
98929922
)
98939923
if err != nil {
98949924
panic(err)

cmd/constants/providers.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
krakenapi "github.com/dydxprotocol/slinky/providers/apis/kraken"
1717
"github.com/dydxprotocol/slinky/providers/apis/marketmap"
1818
"github.com/dydxprotocol/slinky/providers/apis/polymarket"
19+
"github.com/dydxprotocol/slinky/providers/apis/stork"
1920
"github.com/dydxprotocol/slinky/providers/volatile"
2021
binancews "github.com/dydxprotocol/slinky/providers/websockets/binance"
2122
"github.com/dydxprotocol/slinky/providers/websockets/bitfinex"
@@ -162,6 +163,13 @@ var (
162163
Type: types.ConfigType,
163164
},
164165

166+
// Stork provider
167+
{
168+
Name: stork.Name,
169+
API: stork.DefaultAPIConfig,
170+
Type: types.ConfigType,
171+
},
172+
165173
// MarketMap provider
166174
{
167175
Name: marketmap.Name,
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
package stork
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"math/big"
8+
"net/http"
9+
"strings"
10+
"time"
11+
12+
providertypes "github.com/dydxprotocol/slinky/providers/types"
13+
14+
"github.com/dydxprotocol/slinky/oracle/config"
15+
"github.com/dydxprotocol/slinky/oracle/types"
16+
)
17+
18+
var _ types.PriceAPIDataHandler = (*APIHandler)(nil)
19+
20+
// APIHandler implements the PriceAPIDataHandler interface for Stork.
21+
type APIHandler struct {
22+
api config.APIConfig
23+
cache types.ProviderTickers
24+
}
25+
26+
// NewAPIHandler returns a new Stork PriceAPIDataHandler.
27+
func NewAPIHandler(
28+
api config.APIConfig,
29+
) (types.PriceAPIDataHandler, error) {
30+
if api.Name != Name {
31+
return nil, fmt.Errorf("expected api config name %s, got %s", Name, api.Name)
32+
}
33+
34+
if !api.Enabled {
35+
return nil, fmt.Errorf("api config for %s is not enabled", Name)
36+
}
37+
38+
if err := api.ValidateBasic(); err != nil {
39+
return nil, fmt.Errorf("invalid api config for %s: %w", Name, err)
40+
}
41+
42+
return &APIHandler{
43+
api: api,
44+
cache: types.NewProviderTickers(),
45+
}, nil
46+
}
47+
48+
// CreateURL returns the URL used to fetch prices from the Stork API. The asset IDs
49+
// are passed as a comma-separated query parameter.
50+
func (h *APIHandler) CreateURL(
51+
tickers []types.ProviderTicker,
52+
) (string, error) {
53+
if len(tickers) == 0 {
54+
return "", fmt.Errorf("no tickers provided")
55+
}
56+
57+
ids := make([]string, len(tickers))
58+
for i, ticker := range tickers {
59+
ids[i] = ticker.GetOffChainTicker()
60+
h.cache.Add(ticker)
61+
}
62+
63+
return fmt.Sprintf("%s?asset=%s", h.api.Endpoints[0].URL, strings.Join(ids, ",")), nil
64+
}
65+
66+
// scaleFactor is 10^18 as a big.Float, used to divide Stork's scaled price values.
67+
var scaleFactor, _ = new(big.Float).SetString("1000000000000000000")
68+
69+
// ParseResponse parses a batch Stork API response ({"data": [...]}), verifies
70+
// each aggregator's ECDSA signature, and returns prices scaled down by 10^18.
71+
func (h *APIHandler) ParseResponse(
72+
tickers []types.ProviderTicker,
73+
resp *http.Response,
74+
) types.PriceResponse {
75+
body, err := io.ReadAll(resp.Body)
76+
if err != nil {
77+
return types.NewPriceResponseWithErr(
78+
tickers,
79+
providertypes.NewErrorWithCode(
80+
fmt.Errorf("failed to read stork response body: %w", err),
81+
providertypes.ErrorFailedToDecode,
82+
),
83+
)
84+
}
85+
86+
var batch BatchPriceResponse
87+
if err := json.Unmarshal(body, &batch); err != nil {
88+
return types.NewPriceResponseWithErr(
89+
tickers,
90+
providertypes.NewErrorWithCode(
91+
fmt.Errorf("failed to decode stork response: %w", err),
92+
providertypes.ErrorFailedToDecode,
93+
),
94+
)
95+
}
96+
97+
// Index response items by normalized market name (uppercase, no dashes)
98+
// so that "XAU-USD" matches off_chain_ticker "XAUUSD".
99+
byMarket := make(map[string]PriceResponse, len(batch.Data))
100+
for _, item := range batch.Data {
101+
byMarket[normalizeMarket(item.Market)] = item
102+
}
103+
104+
var (
105+
resolved = make(types.ResolvedPrices)
106+
unresolved = make(types.UnResolvedPrices)
107+
)
108+
109+
for _, ticker := range tickers {
110+
offChain := ticker.GetOffChainTicker()
111+
112+
item, ok := byMarket[normalizeMarket(offChain)]
113+
if !ok {
114+
unresolved[ticker] = providertypes.UnresolvedResult{
115+
ErrorWithCode: providertypes.NewErrorWithCode(
116+
fmt.Errorf("no stork response for ticker %s", offChain),
117+
providertypes.ErrorNoResponse,
118+
),
119+
}
120+
continue
121+
}
122+
123+
if err := VerifyStorkSignature(item.StorkSignatureVerification.StorkSignedPrice); err != nil {
124+
unresolved[ticker] = providertypes.UnresolvedResult{
125+
ErrorWithCode: providertypes.NewErrorWithCode(
126+
fmt.Errorf("stork signature verification failed for %s: %w", item.Market, err),
127+
providertypes.ErrorInvalidResponse,
128+
),
129+
}
130+
continue
131+
}
132+
133+
rawPrice, ok := new(big.Float).SetString(item.Price)
134+
if !ok {
135+
unresolved[ticker] = providertypes.UnresolvedResult{
136+
ErrorWithCode: providertypes.NewErrorWithCode(
137+
fmt.Errorf("failed to parse price %s for %s", item.Price, item.Market),
138+
providertypes.ErrorFailedToParsePrice,
139+
),
140+
}
141+
continue
142+
}
143+
144+
price := new(big.Float).Quo(rawPrice, scaleFactor)
145+
resolved[ticker] = types.NewPriceResult(price, time.Now().UTC())
146+
}
147+
148+
return types.NewPriceResponse(resolved, unresolved)
149+
}
150+
151+
// normalizeMarket strips dashes/underscores and uppercases the market name
152+
// so that "XAU-USD" and "XAUUSD" both become "XAUUSD".
153+
func normalizeMarket(s string) string {
154+
s = strings.ReplaceAll(s, "-", "")
155+
s = strings.ReplaceAll(s, "_", "")
156+
return strings.ToUpper(s)
157+
}

0 commit comments

Comments
 (0)