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
13 changes: 12 additions & 1 deletion cmd/constants/providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
coinbaseapi "github.com/skip-mev/connect/v2/providers/apis/coinbase"
"github.com/skip-mev/connect/v2/providers/apis/coingecko"
"github.com/skip-mev/connect/v2/providers/apis/coinmarketcap"
"github.com/skip-mev/connect/v2/providers/apis/defi/curve"
"github.com/skip-mev/connect/v2/providers/apis/defi/osmosis"
"github.com/skip-mev/connect/v2/providers/apis/defi/raydium"
"github.com/skip-mev/connect/v2/providers/apis/defi/uniswapv3"
Expand All @@ -20,6 +21,7 @@ import (
"github.com/skip-mev/connect/v2/providers/volatile"
binancews "github.com/skip-mev/connect/v2/providers/websockets/binance"
"github.com/skip-mev/connect/v2/providers/websockets/bitfinex"
"github.com/skip-mev/connect/v2/providers/websockets/bitget"
"github.com/skip-mev/connect/v2/providers/websockets/bitstamp"
"github.com/skip-mev/connect/v2/providers/websockets/bybit"
"github.com/skip-mev/connect/v2/providers/websockets/coinbase"
Expand Down Expand Up @@ -56,7 +58,11 @@ var (
API: osmosis.DefaultAPIConfig,
Type: types.ConfigType,
},

{
Name: curve.Name,
API: curve.DefaultAPIConfig,
Type: types.ConfigType,
},
// Exchange API providers
{
Name: binanceapi.Name,
Expand Down Expand Up @@ -160,6 +166,11 @@ var (
WebSocket: okx.DefaultWebSocketConfig,
Type: types.ConfigType,
},
{
Name: bitget.Name,
WebSocket: bitget.DefaultWebSocketConfig,
Type: types.ConfigType,
},

// Polymarket provider
{
Expand Down
7 changes: 7 additions & 0 deletions pkg/math/math.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,3 +165,10 @@ func GetScalingFactor(
}
return new(big.Float).Quo(big.NewFloat(1), exp)
}

// Float64ToBigFloat converts a float64 to a big.Float.
func Float64ToBigFloat(val float64) *big.Float {
bigFloat := new(big.Float)
bigFloat.SetFloat64(val)
return bigFloat
}
125 changes: 125 additions & 0 deletions providers/apis/defi/curve/api_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package curve

import (
"encoding/json"
"fmt"
"net/http"
"reflect"
"time"

"github.com/skip-mev/connect/v2/oracle/config"
"github.com/skip-mev/connect/v2/oracle/types"
"github.com/skip-mev/connect/v2/pkg/math"
providertypes "github.com/skip-mev/connect/v2/providers/types"
)

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

type APIHandler struct {
api config.APIConfig

cache types.ProviderTickers
}

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
}

func (h *APIHandler) CreateURL(
tickers []types.ProviderTicker,
) (string, error) {
if len(tickers) == 0 {
return "", fmt.Errorf("no tickers provided")
}

for _, ticker := range tickers {
h.cache.Add(ticker)

var metadata CurveMetadata
metadataJSON := ticker.GetJSON()
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
return h.api.Endpoints[0].URL, fmt.Errorf("failed to parse metadata JSON: %w", err)
}

if metadata.Network == "" {
return h.api.Endpoints[0].URL, fmt.Errorf("network not found in metadata")
}
if !IsSupportedNetwork(metadata.Network) {
return h.api.Endpoints[0].URL, fmt.Errorf("network not supported: %s", metadata.Network)
}
}

return fmt.Sprintf(h.api.Endpoints[0].URL), nil
}

func (h *APIHandler) ParseResponse(
tickers []types.ProviderTicker,
resp *http.Response,
) types.PriceResponse {
var result CurveResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return types.NewPriceResponseWithErr(
tickers,
providertypes.NewErrorWithCode(err, providertypes.ErrorFailedToDecode),
)
}

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

if reflect.DeepEqual(result.Data, CurveMetadata{}) {
err := fmt.Errorf("received empty data in response")
return types.NewPriceResponseWithErr(
tickers,
providertypes.NewErrorWithCode(err, providertypes.ErrorNoResponse),
)
}

for _, data := range result.Data {
ticker, ok := h.cache.FromOffChainTicker(data.Address)
if !ok {
err := fmt.Errorf("no ticker for address %s", data.Address)
return types.NewPriceResponseWithErr(
tickers,
providertypes.NewErrorWithCode(err, providertypes.ErrorUnknownPair),
)
}

price := math.Float64ToBigFloat(data.UsdPrice)

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

for _, ticker := range tickers {
_, resolvedOk := resolved[ticker]
_, unresolvedOk := unresolved[ticker]

if !resolvedOk && !unresolvedOk {
err := fmt.Errorf("received no price response")
unresolved[ticker] = providertypes.UnresolvedResult{
ErrorWithCode: providertypes.NewErrorWithCode(err, providertypes.ErrorNoResponse),
}
}
}

return types.NewPriceResponse(resolved, unresolved)
}
209 changes: 209 additions & 0 deletions providers/apis/defi/curve/api_handler_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
package curve

import (
"encoding/json"
"github.com/skip-mev/connect/v2/providers/base/testutils"
providertypes "github.com/skip-mev/connect/v2/providers/types"
"net/http"
"testing"
"time"

"github.com/skip-mev/connect/v2/oracle/config"
"github.com/skip-mev/connect/v2/oracle/types"
"github.com/stretchr/testify/require"
)

func TestNewAPIHandler(t *testing.T) {
tests := []struct {
name string
apiConfig config.APIConfig
wantErr bool
}{
{
name: "success - default config",
apiConfig: DefaultAPIConfig,
wantErr: false,
},
{
name: "failure - wrong API name",
apiConfig: config.APIConfig{
Name: "wrong_name",
Enabled: true,
},
wantErr: true,
},
{
name: "failure - disabled API",
apiConfig: config.APIConfig{
Name: Name,
Enabled: false,
},
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
handler, err := NewAPIHandler(tt.apiConfig)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.NotNil(t, handler)
})
}
}

func TestCreateURL(t *testing.T) {
handler, err := NewAPIHandler(DefaultAPIConfig)
require.NoError(t, err)

tests := []struct {
name string
tickers []types.ProviderTicker
wantURL string
wantErr bool
metadata CurveMetadata
}{
{
name: "failure - empty tickers",
tickers: []types.ProviderTicker{},
wantErr: true,
},
{
name: "success - single ticker",
tickers: []types.ProviderTicker{
createTickerWithMetadata(t, "ETH", "USD", CurveMetadata{
Network: "ethereum",
BaseTokenAddress: "0x123",
}),
},
metadata: CurveMetadata{
Network: "ethereum",
BaseTokenAddress: "0x123",
},
wantURL: "https://prices.curve.fi/v1/usd_price/ethereum",
wantErr: false,
},
{
name: "failure - missing network",
tickers: []types.ProviderTicker{
createTickerWithMetadata(t, "ETH", "USD", CurveMetadata{
BaseTokenAddress: "0x123",
}),
},
metadata: CurveMetadata{
BaseTokenAddress: "0x123",
},
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
url, err := handler.CreateURL(tt.tickers)
if tt.wantErr {
require.Error(t, err)
return
}

require.NoError(t, err)
require.Equal(t, tt.wantURL, url)
})
}
}

func TestParseResponse(t *testing.T) {
testCases := []struct {
name string
ticker types.ProviderTicker
response *http.Response
expectedPrice float64
expectError bool
errorCode providertypes.ErrorCode
}{
{
name: "valid response",
ticker: types.DefaultProviderTicker{
OffChainTicker: "0xCd5fE23C85820F7B72D0926FC9b05b43E359b7ee",
JSON: `{"network":"ethereum","base_token_address":"0xCd5fE23C85820F7B72D0926FC9b05b43E359b7ee"}`,
},
response: testutils.CreateResponseFromJSON(`{
"data": [{
"address": "0xCd5fE23C85820F7B72D0926FC9b05b43E359b7ee",
"usd_price": 1674.1742629502855,
"last_updated": "2025-04-16T06:04:23"
}]
}`),
expectedPrice: 1674.1742629502855,
expectError: false,
},
{
name: "invalid JSON response",
ticker: types.DefaultProviderTicker{
OffChainTicker: "0xAddress",
JSON: `{"network":"ethereum","base_token_address":"0xAddress"}`,
},
response: testutils.CreateResponseFromJSON(`{invalid json`),
expectError: true,
errorCode: providertypes.ErrorFailedToDecode,
},
{
name: "empty data response",
ticker: types.DefaultProviderTicker{
OffChainTicker: "0xAddress",
JSON: `{"network":"ethereum","base_token_address":"0xAddress"}`,
},
response: testutils.CreateResponseFromJSON(`{"data": {whole list of ethereum ...}}`),
expectError: true,
errorCode: providertypes.ErrorFailedToDecode,
},
{
name: "unresolved ticker",
ticker: types.DefaultProviderTicker{
OffChainTicker: "0xUnknownAddress",
JSON: `{"network":"ethereum","base_token_address":"0xUnknownAddress"}`,
},
response: testutils.CreateResponseFromJSON(`{"detail":"Token data not found"}`),
expectError: true,
errorCode: providertypes.ErrorNoResponse,
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
h, err := NewAPIHandler(DefaultAPIConfig)
require.NoError(t, err)

// First create URL to populate cache
_, err = h.CreateURL([]types.ProviderTicker{tc.ticker})
require.NoError(t, err)

resp := h.ParseResponse([]types.ProviderTicker{tc.ticker}, tc.response)

if tc.expectError {
require.Len(t, resp.Resolved, 0)
require.Len(t, resp.UnResolved, 1)
require.Equal(t, tc.errorCode, resp.UnResolved[tc.ticker].ErrorWithCode.Code())
} else {
require.Len(t, resp.Resolved, 1)
require.Len(t, resp.UnResolved, 0)
price, _ := resp.Resolved[tc.ticker].Value.Float64()
require.InDelta(t, tc.expectedPrice, price, 0.0001)
require.True(t, resp.Resolved[tc.ticker].Timestamp.After(time.Now().Add(-time.Second)))
}
})
}
}

// createTickerWithMetadata is a helper function to create a ProviderTicker with metadata
func createTickerWithMetadata(t *testing.T, base, quote string, metadata CurveMetadata) types.ProviderTicker {
metadataBytes, err := json.Marshal(metadata)
require.NoError(t, err)

return types.DefaultProviderTicker{
OffChainTicker: base + "/" + quote,
JSON: string(metadataBytes),
}
}
Loading