diff --git a/.github/actions/spelling/allow.txt b/.github/actions/spelling/allow.txt index 5c7f2a797..ca6ed2150 100644 --- a/.github/actions/spelling/allow.txt +++ b/.github/actions/spelling/allow.txt @@ -1,4 +1,5 @@ github +unmarshalls https ssh ubuntu diff --git a/providers/apis/polymarket/README.md b/providers/apis/polymarket/README.md index c4c015f03..64c8b8a63 100644 --- a/providers/apis/polymarket/README.md +++ b/providers/apis/polymarket/README.md @@ -16,12 +16,17 @@ The offchain ticker is expected to be _just_ the token_id. example: `95128817762909535143571435260705470642391662537976312011260538371392879420759` -The Provider simply calls the `/price` endpoint of the CLOB API. There are two query parameters: +The Provider can handle both the midpoint and the price endpoints. However, passing in multiple endpoints to the same provider will not yield additional data, as only the first endpoint is considered for the provider. -* token_id -* side +Example: -Side can be either `buy` or `sell`. For this provider, we hardcode the side to `buy`. +Midpoint: + +`https://clob.polymarket.com/midpoint?token_id=95128817762909535143571435260705470642391662537976312011260538371392879420759` + +Price: + +`https://clob.polymarket.com/price?token_id=95128817762909535143571435260705470642391662537976312011260538371392879420759&side=BUY` ## Market Config @@ -71,7 +76,7 @@ Below is an example of an oracle config with a Polymarket provider. "atomic": true, "endpoints": [ { - "url": "https://clob.polymarket.com/price?token_id=%s&side=BUY", + "url": "https://clob.polymarket.com/midpoint?token_id=%s", "authentication": { "apiKey": "", "apiKeyHeader": "" diff --git a/providers/apis/polymarket/api_handler.go b/providers/apis/polymarket/api_handler.go index 8bbbac3af..22f9778e3 100644 --- a/providers/apis/polymarket/api_handler.go +++ b/providers/apis/polymarket/api_handler.go @@ -3,10 +3,15 @@ package polymarket import ( "encoding/json" "fmt" + "io" "math/big" "net/http" + "net/url" + "strings" "time" + "golang.org/x/exp/maps" + "github.com/skip-mev/slinky/oracle/config" "github.com/skip-mev/slinky/oracle/types" providertypes "github.com/skip-mev/slinky/providers/types" @@ -16,18 +21,27 @@ const ( // Name is the name of the Polymarket provider. Name = "polymarket_api" - // URL is the base URL of the Polymarket CLOB API endpoint for the Price of a given token ID. - URL = "https://clob.polymarket.com/price?token_id=%s&side=BUY" + host = "clob.polymarket.com" + // URL is the default base URL of the Polymarket CLOB API. It uses the midpoint endpoint with a given token ID. + URL = "https://clob.polymarket.com/midpoint?token_id=%s" // priceAdjustmentMax is the value the price gets set to in the event of price == 1.00. priceAdjustmentMax = .9999 priceAdjustmentMin = .00001 ) -var _ types.PriceAPIDataHandler = (*APIHandler)(nil) +var ( + _ types.PriceAPIDataHandler = (*APIHandler)(nil) + + // valueExtractorFromEndpoint maps a URL path to a function that can extract the returned data from the response of that endpoint. + valueExtractorFromEndpoint = map[string]valueExtractor{ + "/midpoint": dataFromMidpoint, + "/price": dataFromPrice, + } +) // APIHandler implements the PriceAPIDataHandler interface for Polymarket, which can be used -// by a base provider. The handler fetches data from the `/price` endpoint. +// by a base provider. The handler fetches data from either the `/midpoint` or `/price` endpoint. type APIHandler struct { api config.APIConfig } @@ -46,13 +60,30 @@ func NewAPIHandler(api config.APIConfig) (types.PriceAPIDataHandler, error) { return nil, fmt.Errorf("invalid api config for %s: %w", Name, err) } + if len(api.Endpoints) != 1 { + return nil, fmt.Errorf("invalid polymarket endpoint config: expected 1 endpoint got %d", len(api.Endpoints)) + } + + u, err := url.Parse(api.Endpoints[0].URL) + if err != nil { + return nil, fmt.Errorf("invalid polymarket endpoint url %q: %w", api.Endpoints[0].URL, err) + } + + if u.Host != host { + return nil, fmt.Errorf("invalid polymarket URL: expected %q got %q", host, u.Host) + } + + if _, exists := valueExtractorFromEndpoint[u.Path]; !exists { + return nil, fmt.Errorf("invalid polymarket endpoint url path %s. endpoint must be one of: %s", u.Path, strings.Join(maps.Keys(valueExtractorFromEndpoint), ",")) + } + return &APIHandler{ api: api, }, nil } // CreateURL returns the URL that is used to fetch data from the Polymarket API for the -// given ticker. Since the price endpoint is automatically denominated in USD, only one ID is expected to be passed +// given ticker. Since the midpoint endpoint is automatically denominated in USD, only one ID is expected to be passed // into this method. func (h APIHandler) CreateURL(ids []types.ProviderTicker) (string, error) { if len(ids) != 1 { @@ -61,13 +92,42 @@ func (h APIHandler) CreateURL(ids []types.ProviderTicker) (string, error) { return fmt.Sprintf(h.api.Endpoints[0].URL, ids[0].GetOffChainTicker()), nil } -// ResponseBody is the response structure for the `/price` endpoint of the Polymarket API. -type ResponseBody struct { +// midpointResponseBody is the response structure for the `/midpoint` endpoint of the Polymarket API. +type midpointResponseBody struct { + Mid string `json:"mid"` +} + +// priceResponseBody is the response structure for the `/price` endpoint of the Polymarket API. +type priceResponseBody struct { Price string `json:"price"` } -// ParseResponse parses the HTTP response from the `/price` Polymarket API endpoint and returns -// the resulting price. +// valueExtractor is a function that can extract (price, midpoint) from a http response body. +// This function is expected to return a sting representation of a float. +type valueExtractor func(io.ReadCloser) (string, error) + +// dataFromPrice unmarshalls data from the /price endpoint. +func dataFromPrice(reader io.ReadCloser) (string, error) { + var result priceResponseBody + err := json.NewDecoder(reader).Decode(&result) + if err != nil { + return "", err + } + return result.Price, nil +} + +// dataFromMidpoint unmarshalls data from the /midpoint endpoint. +func dataFromMidpoint(reader io.ReadCloser) (string, error) { + var result midpointResponseBody + err := json.NewDecoder(reader).Decode(&result) + if err != nil { + return "", err + } + return result.Mid, nil +} + +// ParseResponse parses the HTTP response from either the `/price` or `/midpoint` endpoint of the Polymarket API endpoint and returns +// the resulting data. func (h APIHandler) ParseResponse(ids []types.ProviderTicker, response *http.Response) types.PriceResponse { if len(ids) != 1 { return types.NewPriceResponseWithErr( @@ -79,8 +139,17 @@ func (h APIHandler) ParseResponse(ids []types.ProviderTicker, response *http.Res ) } - var result ResponseBody - err := json.NewDecoder(response.Body).Decode(&result) + // get the extractor function for this endpoint. + extractor, ok := valueExtractorFromEndpoint[response.Request.URL.Path] + if !ok { + return types.NewPriceResponseWithErr( + ids, + providertypes.NewErrorWithCode(fmt.Errorf("unknown request path %q", response.Request.URL.Path), providertypes.ErrorFailedToDecode), + ) + } + + // extract the value. it should be a string representation of a float. + val, err := extractor(response.Body) if err != nil { return types.NewPriceResponseWithErr( ids, @@ -88,11 +157,11 @@ func (h APIHandler) ParseResponse(ids []types.ProviderTicker, response *http.Res ) } - price, ok := new(big.Float).SetString(result.Price) + price, ok := new(big.Float).SetString(val) if !ok { return types.NewPriceResponseWithErr( ids, - providertypes.NewErrorWithCode(fmt.Errorf("failed to convert %q to float", result.Price), providertypes.ErrorFailedToDecode), + providertypes.NewErrorWithCode(fmt.Errorf("failed to convert %q to float", val), providertypes.ErrorFailedToDecode), ) } if err := validatePrice(price); err != nil { diff --git a/providers/apis/polymarket/api_handler_test.go b/providers/apis/polymarket/api_handler_test.go index d4e5fec23..1c72e173a 100644 --- a/providers/apis/polymarket/api_handler_test.go +++ b/providers/apis/polymarket/api_handler_test.go @@ -6,11 +6,13 @@ import ( "io" "math/big" "net/http" + "net/url" "testing" "time" "github.com/stretchr/testify/require" + "github.com/skip-mev/slinky/oracle/config" "github.com/skip-mev/slinky/oracle/types" providertypes "github.com/skip-mev/slinky/providers/types" ) @@ -19,6 +21,84 @@ var candidateWinsElectionToken = types.DefaultProviderTicker{ OffChainTicker: "95128817762909535143571435260705470642391662537976312011260538371392879420759", } +func TestNewAPIHandler(t *testing.T) { + tests := []struct { + name string + modifyConfig func(config.APIConfig) config.APIConfig + expectError bool + errorMsg string + }{ + { + name: "Valid configuration", + modifyConfig: func(cfg config.APIConfig) config.APIConfig { + return cfg // No modifications + }, + expectError: false, + }, + { + name: "Invalid name", + modifyConfig: func(cfg config.APIConfig) config.APIConfig { + cfg.Name = "InvalidName" + return cfg + }, + expectError: true, + errorMsg: "expected api config name polymarket_api, got InvalidName", + }, + { + name: "Too many endpoints", + modifyConfig: func(cfg config.APIConfig) config.APIConfig { + cfg.Endpoints = append(cfg.Endpoints, cfg.Endpoints...) + return cfg + }, + expectError: true, + errorMsg: "invalid polymarket endpoint config: expected 1 endpoint got 2", + }, + { + name: "Disabled API", + modifyConfig: func(cfg config.APIConfig) config.APIConfig { + cfg.Enabled = false + return cfg + }, + expectError: true, + errorMsg: "api config for polymarket_api is not enabled", + }, + { + name: "Invalid host", + modifyConfig: func(cfg config.APIConfig) config.APIConfig { + cfg.Endpoints[0].URL = "https://foobar.com/price" + return cfg + }, + expectError: true, + errorMsg: "invalid polymarket URL: expected", + }, + { + name: "Invalid endpoint path", + modifyConfig: func(cfg config.APIConfig) config.APIConfig { + cfg.Endpoints[0].URL = "https://" + host + "/foo" + return cfg + }, + expectError: true, + errorMsg: `invalid polymarket endpoint url path /foo`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := DefaultAPIConfig + cfg.Endpoints = append([]config.Endpoint{}, DefaultAPIConfig.Endpoints...) + modifiedConfig := tt.modifyConfig(cfg) + _, err := NewAPIHandler(modifiedConfig) + if tt.expectError { + fmt.Println(err.Error()) + require.Error(t, err) + require.ErrorContains(t, err, tt.errorMsg) + } else { + require.NoError(t, err) + } + }) + } +} + func TestCreateURL(t *testing.T) { testCases := []struct { name string @@ -68,13 +148,28 @@ func TestParseResponse(t *testing.T) { require.NoError(t, err) testCases := []struct { name string + path string noError bool ids []types.ProviderTicker responseBody string expectedResponse types.PriceResponse }{ { - name: "happy case", + name: "happy case from midpoint", + path: "/midpoint", + ids: []types.ProviderTicker{candidateWinsElectionToken}, + noError: true, + responseBody: `{ "mid": "0.45" }`, + expectedResponse: types.NewPriceResponse( + types.ResolvedPrices{ + id: types.NewPriceResult(big.NewFloat(0.45), time.Now().UTC()), + }, + nil, + ), + }, + { + name: "happy case from price", + path: "/price", ids: []types.ProviderTicker{candidateWinsElectionToken}, noError: true, responseBody: `{ "price": "0.45" }`, @@ -85,11 +180,22 @@ func TestParseResponse(t *testing.T) { nil, ), }, + { + name: "bad path", + path: "/foobar", + ids: []types.ProviderTicker{candidateWinsElectionToken}, + responseBody: `{"mid": "234.3"}"`, + expectedResponse: types.NewPriceResponseWithErr( + []types.ProviderTicker{candidateWinsElectionToken}, + providertypes.NewErrorWithCode(fmt.Errorf("unknown request path %q", "/foobar"), providertypes.ErrorFailedToDecode), + ), + }, { name: "1.00 should resolve to 0.999...", + path: "/midpoint", ids: []types.ProviderTicker{candidateWinsElectionToken}, noError: true, - responseBody: `{ "price": "1.00" }`, + responseBody: `{ "mid": "1.00" }`, expectedResponse: types.NewPriceResponse( types.ResolvedPrices{ id: types.NewPriceResult(big.NewFloat(priceAdjustmentMax), time.Now().UTC()), @@ -99,9 +205,10 @@ func TestParseResponse(t *testing.T) { }, { name: "0.00 should resolve to 0.00001", + path: "/midpoint", ids: []types.ProviderTicker{candidateWinsElectionToken}, noError: true, - responseBody: `{ "price": "0.00" }`, + responseBody: `{ "mid": "0.00" }`, expectedResponse: types.NewPriceResponse( types.ResolvedPrices{ id: types.NewPriceResult(big.NewFloat(priceAdjustmentMin), time.Now().UTC()), @@ -111,6 +218,7 @@ func TestParseResponse(t *testing.T) { }, { name: "too many IDs", + path: "/midpoint", ids: []types.ProviderTicker{candidateWinsElectionToken, candidateWinsElectionToken}, responseBody: ``, expectedResponse: types.NewPriceResponseWithErr( @@ -123,8 +231,9 @@ func TestParseResponse(t *testing.T) { }, { name: "invalid JSON", + path: "/midpoint", ids: []types.ProviderTicker{candidateWinsElectionToken}, - responseBody: `{"price": "0fa3adk"}"`, + responseBody: `{"mid": "0fa3adk"}"`, expectedResponse: types.NewPriceResponseWithErr( []types.ProviderTicker{candidateWinsElectionToken}, providertypes.NewErrorWithCode(fmt.Errorf("failed to convert %q to float", "0fa3adk"), providertypes.ErrorFailedToDecode), @@ -132,8 +241,9 @@ func TestParseResponse(t *testing.T) { }, { name: "bad price - max", + path: "/midpoint", ids: []types.ProviderTicker{candidateWinsElectionToken}, - responseBody: `{"price": "1.0001"}"`, + responseBody: `{"mid": "1.0001"}"`, expectedResponse: types.NewPriceResponseWithErr( []types.ProviderTicker{candidateWinsElectionToken}, providertypes.NewErrorWithCode(fmt.Errorf("price exceeded 1.00"), providertypes.ErrorInvalidResponse), @@ -141,8 +251,9 @@ func TestParseResponse(t *testing.T) { }, { name: "bad price - negative", + path: "/midpoint", ids: []types.ProviderTicker{candidateWinsElectionToken}, - responseBody: `{"price": "-0.12"}"`, + responseBody: `{"mid": "-0.12"}"`, expectedResponse: types.NewPriceResponseWithErr( []types.ProviderTicker{candidateWinsElectionToken}, providertypes.NewErrorWithCode(fmt.Errorf("price must be greater than 0.00"), providertypes.ErrorInvalidResponse), @@ -153,7 +264,8 @@ func TestParseResponse(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { httpInput := &http.Response{ - Body: io.NopCloser(bytes.NewBufferString(tc.responseBody)), + Body: io.NopCloser(bytes.NewBufferString(tc.responseBody)), + Request: &http.Request{URL: &url.URL{Path: tc.path}}, } res := handler.ParseResponse(tc.ids, httpInput)