Skip to content

Commit

Permalink
refactor: polymarket provider supports both /price and /midpoint (#656)
Browse files Browse the repository at this point in the history
Co-authored-by: Alex Johnson <[email protected]>
  • Loading branch information
technicallyty and aljo242 authored Aug 9, 2024
1 parent bf43357 commit 1b4431a
Show file tree
Hide file tree
Showing 4 changed files with 212 additions and 25 deletions.
1 change: 1 addition & 0 deletions .github/actions/spelling/allow.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
github
unmarshalls
https
ssh
ubuntu
Expand Down
15 changes: 10 additions & 5 deletions providers/apis/polymarket/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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": ""
Expand Down
95 changes: 82 additions & 13 deletions providers/apis/polymarket/api_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
}
Expand All @@ -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 {
Expand All @@ -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(
Expand All @@ -79,20 +139,29 @@ 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,
providertypes.NewErrorWithCode(err, providertypes.ErrorFailedToDecode),
)
}

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 {
Expand Down
Loading

0 comments on commit 1b4431a

Please sign in to comment.