diff --git a/token-price-oracle/README.md b/token-price-oracle/README.md index 0fa987961..decefd4af 100644 --- a/token-price-oracle/README.md +++ b/token-price-oracle/README.md @@ -4,7 +4,7 @@ Token Price Oracle service monitors token prices and updates the price ratio bet ## Features -- **Real-time Price Monitoring**: Fetches token USD prices from exchange APIs (Bitget) +- **Real-time Price Monitoring**: Fetches token USD prices from Chainlink, Pyth Hermes, Bitget, Binance, and OKX - **Price Ratio Calculation**: Computes price ratio between tokens and ETH - **Threshold-based Updates**: Only updates on-chain when price change exceeds threshold, saving Gas - **Batch Updates**: Updates multiple token prices in a single `batchUpdatePrices` transaction @@ -23,6 +23,18 @@ export TOKEN_PRICE_ORACLE_PRIVATE_KEY="0x..." # Required for local signing only export TOKEN_PRICE_ORACLE_BITGET_API_BASE_URL="https://api.bitget.com" export TOKEN_PRICE_ORACLE_TOKEN_MAPPING_BITGET="1:BTCUSDT,2:ETHUSDT" +# Optional: prefer oracle feeds first, fallback to CEX sources +export TOKEN_PRICE_ORACLE_PRICE_FEED_PRIORITY="chainlink,pyth,bitget,binance,okx" +export TOKEN_PRICE_ORACLE_CHAINLINK_RPC="https://ethereum-rpc.publicnode.com" +export TOKEN_PRICE_ORACLE_CHAINLINK_ETH_USD_FEED="0x..." +export TOKEN_PRICE_ORACLE_CHAINLINK_MAX_STALENESS="1h" +export TOKEN_PRICE_ORACLE_TOKEN_MAPPING_CHAINLINK="1:0x...,2:0x..." +export TOKEN_PRICE_ORACLE_PYTH_HERMES_BASE_URL="https://hermes.pyth.network" +export TOKEN_PRICE_ORACLE_PYTH_API_KEY="..." +export TOKEN_PRICE_ORACLE_PYTH_ETH_USD_PRICE_ID="0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace" +export TOKEN_PRICE_ORACLE_PYTH_MAX_STALENESS="1h" +export TOKEN_PRICE_ORACLE_TOKEN_MAPPING_PYTH="1:0x...,2:0x..." + # Optional export TOKEN_PRICE_ORACLE_PRICE_UPDATE_INTERVAL="1m" export TOKEN_PRICE_ORACLE_PRICE_THRESHOLD="100" # 1% (100 bps) @@ -59,8 +71,9 @@ docker run -d \ | Environment Variable | Description | |---------------------|-------------| | `TOKEN_PRICE_ORACLE_L2_ETH_RPC` | L2 node RPC endpoint | -| `TOKEN_PRICE_ORACLE_BITGET_API_BASE_URL` | Bitget API base URL | -| `TOKEN_PRICE_ORACLE_TOKEN_MAPPING_BITGET` | TokenID to trading pair mapping | +| `TOKEN_PRICE_ORACLE_PRICE_FEED_PRIORITY` | Enabled price feeds in fallback order | + +Each enabled price feed also requires its own mapping/configuration in the feed sections below. ### Required (Local Signing Mode Only) @@ -74,13 +87,56 @@ docker run -d \ |---------------------|---------|-------------| | `TOKEN_PRICE_ORACLE_PRICE_UPDATE_INTERVAL` | `1m` | Price update interval | | `TOKEN_PRICE_ORACLE_PRICE_THRESHOLD` | `100` | Update threshold (basis points, 100=1%) | -| `TOKEN_PRICE_ORACLE_PRICE_FEED_PRIORITY` | `bitget` | Price feed priority | +| `TOKEN_PRICE_ORACLE_PRICE_FEED_PRIORITY` | `bitget` | Price feed priority (`chainlink`, `pyth`, `bitget`, `binance`, `okx`) | | `TOKEN_PRICE_ORACLE_METRICS_SERVER_ENABLE` | `false` | Enable metrics server | | `TOKEN_PRICE_ORACLE_METRICS_HOSTNAME` | `0.0.0.0` | Metrics server hostname | | `TOKEN_PRICE_ORACLE_METRICS_PORT` | `6060` | Metrics server port | | `TOKEN_PRICE_ORACLE_LOG_LEVEL` | `info` | Log level | | `TOKEN_PRICE_ORACLE_LOG_FILENAME` | - | Log file path | +### Chainlink Feed + +| Environment Variable | Default | Description | +|---------------------|---------|-------------| +| `TOKEN_PRICE_ORACLE_CHAINLINK_RPC` | - | RPC endpoint used to read Chainlink AggregatorV3 feeds | +| `TOKEN_PRICE_ORACLE_CHAINLINK_ETH_USD_FEED` | - | Chainlink ETH/USD AggregatorV3 feed address | +| `TOKEN_PRICE_ORACLE_CHAINLINK_MAX_STALENESS` | `1h` | Maximum accepted age of Chainlink rounds | +| `TOKEN_PRICE_ORACLE_TOKEN_MAPPING_CHAINLINK` | - | TokenID to token/USD AggregatorV3 feed mapping | + +### Pyth Hermes Feed + +Pyth is consumed as an off-chain Hermes data source. The service reads parsed prices from Hermes and still writes the existing `priceRatio` to `L2TokenRegistry`; it does not submit Pyth updates on-chain. + +| Environment Variable | Default | Description | +|---------------------|---------|-------------| +| `TOKEN_PRICE_ORACLE_PYTH_HERMES_BASE_URL` | `https://hermes.pyth.network` | Pyth Hermes API base URL | +| `TOKEN_PRICE_ORACLE_PYTH_API_KEY` | - | Optional Pyth Hermes API key for authenticated requests | +| `TOKEN_PRICE_ORACLE_PYTH_ETH_USD_PRICE_ID` | - | Pyth ETH/USD price ID | +| `TOKEN_PRICE_ORACLE_PYTH_MAX_STALENESS` | `1h` | Maximum accepted age of Pyth publish time | +| `TOKEN_PRICE_ORACLE_PYTH_MAX_CONFIDENCE_BPS` | `0` | Maximum confidence interval relative to price in BPS; `0` disables the check | +| `TOKEN_PRICE_ORACLE_TOKEN_MAPPING_PYTH` | - | TokenID to token/USD Pyth price ID mapping | + +### CEX Feeds + +| Environment Variable | Default | Description | +|---------------------|---------|-------------| +| `TOKEN_PRICE_ORACLE_BITGET_API_BASE_URL` | - | Bitget API base URL, required when Bitget is enabled | +| `TOKEN_PRICE_ORACLE_TOKEN_MAPPING_BITGET` | - | TokenID to Bitget trading pair mapping, e.g. `1:BTCUSDT` | +| `TOKEN_PRICE_ORACLE_BINANCE_API_BASE_URL` | `https://api.binance.com` | Binance API base URL | +| `TOKEN_PRICE_ORACLE_TOKEN_MAPPING_BINANCE` | - | TokenID to Binance trading pair mapping, e.g. `1:BTCUSDT` | +| `TOKEN_PRICE_ORACLE_OKX_API_BASE_URL` | `https://www.okx.com` | OKX API base URL | +| `TOKEN_PRICE_ORACLE_TOKEN_MAPPING_OKX` | - | TokenID to OKX instrument mapping, e.g. `1:BTC-USDT` | + +Example priority with oracle feeds first and CEX fallback: + +```bash +TOKEN_PRICE_ORACLE_PRICE_FEED_PRIORITY=chainlink,pyth,bitget,binance,okx +TOKEN_PRICE_ORACLE_TOKEN_MAPPING_CHAINLINK=1:0x...,2:0x... +TOKEN_PRICE_ORACLE_TOKEN_MAPPING_PYTH=1:0x...,2:0x... +TOKEN_PRICE_ORACLE_TOKEN_MAPPING_BINANCE=1:BTCUSDT,2:ETHUSDT +TOKEN_PRICE_ORACLE_TOKEN_MAPPING_OKX=1:BTC-USDT,2:ETH-USDT +``` + ### External Signing (Recommended for Production) | Environment Variable | Description | @@ -154,11 +210,14 @@ token-price-oracle/ ├── cmd/ # Entry point ├── flags/ # CLI flags definition ├── config/ # Configuration loading -├── client/ # Client wrappers -│ ├── l2_client.go # L2 chain client -│ ├── price_feed.go # Price feed interface -│ ├── bitget_sdk.go # Bitget API client -│ └── sign.go # External signing +├── client/ # Client wrappers +│ ├── l2_client.go # L2 chain client +│ ├── price_feed.go # Price feed interface +│ ├── bitget_sdk.go # Bitget API client +│ ├── cex_feed.go # Binance and OKX API clients +│ ├── chainlink_feed.go # Chainlink AggregatorV3 client +│ ├── pyth_feed.go # Pyth Hermes client +│ └── sign.go # External signing ├── updater/ # Update logic │ ├── token_price.go # Price updater │ ├── tx_manager.go # Transaction manager diff --git a/token-price-oracle/client/cex_feed.go b/token-price-oracle/client/cex_feed.go new file mode 100644 index 000000000..aa2517762 --- /dev/null +++ b/token-price-oracle/client/cex_feed.go @@ -0,0 +1,249 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" + "io" + "math/big" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" + + "github.com/morph-l2/go-ethereum/log" +) + +const ( + binanceTickerPath = "/api/v3/ticker/price" + okxTickerPath = "/api/v5/market/ticker" +) + +type cexPriceFetcher func(ctx context.Context, httpClient *http.Client, baseURL string, symbol string) (*big.Float, error) + +// CEXPriceFeed fetches token prices from a centralized exchange REST API. +type CEXPriceFeed struct { + httpClient *http.Client + mu sync.RWMutex + tokenMap map[uint16]string + ethSymbol string + ethPrice *big.Float + source string + log log.Logger + baseURL string + fetcher cexPriceFetcher +} + +// NewBinancePriceFeed creates a Binance REST price feed. +func NewBinancePriceFeed(tokenMap map[uint16]string, baseURL string) *CEXPriceFeed { + return newCEXPriceFeed("binance", tokenMap, baseURL, "ETHUSDT", fetchBinancePrice) +} + +// NewOKXPriceFeed creates an OKX REST price feed. +func NewOKXPriceFeed(tokenMap map[uint16]string, baseURL string) *CEXPriceFeed { + return newCEXPriceFeed("okx", tokenMap, baseURL, "ETH-USDT", fetchOKXPrice) +} + +func newCEXPriceFeed(source string, tokenMap map[uint16]string, baseURL string, ethSymbol string, fetcher cexPriceFetcher) *CEXPriceFeed { + return &CEXPriceFeed{ + httpClient: &http.Client{Timeout: 10 * time.Second}, + tokenMap: tokenMap, + ethSymbol: ethSymbol, + ethPrice: big.NewFloat(0), + source: source, + log: log.New("component", source+"_price_feed"), + baseURL: baseURL, + fetcher: fetcher, + } +} + +// GetTokenPrice returns token price in USD. +func (f *CEXPriceFeed) GetTokenPrice(ctx context.Context, tokenID uint16) (*TokenPrice, error) { + f.mu.RLock() + symbol, exists := f.tokenMap[tokenID] + ethPrice := new(big.Float).Copy(f.ethPrice) + f.mu.RUnlock() + + if !exists { + return nil, fmt.Errorf("token ID %d not mapped to %s trading pair", tokenID, f.source) + } + if ethPrice.Cmp(big.NewFloat(0)) == 0 { + return nil, fmt.Errorf("ETH price not initialized, please call GetBatchTokenPrices first") + } + + tokenPrice, err := f.fetchMappedPrice(ctx, symbol) + if err != nil { + return nil, fmt.Errorf("failed to fetch %s price for %s: %w", f.source, symbol, err) + } + + f.log.Info("Fetched price from CEX", + "source", f.source, + "token_id", tokenID, + "symbol", symbol, + "token_price_usd", tokenPrice.String(), + "eth_price_usd", ethPrice.String()) + + return &TokenPrice{ + TokenID: tokenID, + Symbol: symbol, + TokenPriceUSD: tokenPrice, + EthPriceUSD: ethPrice, + }, nil +} + +// GetBatchTokenPrices returns batch token prices in USD. +func (f *CEXPriceFeed) GetBatchTokenPrices(ctx context.Context, tokenIDs []uint16) (map[uint16]*TokenPrice, error) { + if err := f.updateETHPrice(ctx); err != nil { + return nil, fmt.Errorf("failed to update ETH price: %w", err) + } + + prices := make(map[uint16]*TokenPrice, len(tokenIDs)) + for _, tokenID := range tokenIDs { + price, err := f.GetTokenPrice(ctx, tokenID) + if err != nil { + f.log.Warn("Failed to get price for token, skipping", + "source", f.source, + "token_id", tokenID, + "error", err) + continue + } + prices[tokenID] = price + } + return prices, nil +} + +func (f *CEXPriceFeed) updateETHPrice(ctx context.Context) error { + price, err := f.fetcher(ctx, f.httpClient, f.baseURL, f.ethSymbol) + if err != nil { + return fmt.Errorf("failed to fetch ETH price from %s: %w", f.source, err) + } + + f.mu.Lock() + f.ethPrice = price + f.mu.Unlock() + + f.log.Info("Fetched ETH price from CEX", + "source", f.source, + "symbol", f.ethSymbol, + "eth_price_usd", price.String()) + return nil +} + +func (f *CEXPriceFeed) fetchMappedPrice(ctx context.Context, symbol string) (*big.Float, error) { + if strings.HasPrefix(symbol, StablecoinPrefix) { + return parseFixedStablecoinPrice(symbol) + } + return f.fetcher(ctx, f.httpClient, f.baseURL, symbol) +} + +func parseFixedStablecoinPrice(symbol string) (*big.Float, error) { + priceStr := strings.TrimPrefix(symbol, StablecoinPrefix) + fixedPrice, err := strconv.ParseFloat(priceStr, 64) + if err != nil { + return nil, fmt.Errorf("invalid stablecoin price format '%s': %w", symbol, err) + } + if fixedPrice <= 0 { + return nil, fmt.Errorf("stablecoin price must be positive, got '%s'", symbol) + } + return big.NewFloat(fixedPrice), nil +} + +type binanceTickerResponse struct { + Symbol string `json:"symbol"` + Price string `json:"price"` +} + +func fetchBinancePrice(ctx context.Context, httpClient *http.Client, baseURL string, symbol string) (*big.Float, error) { + requestURL := fmt.Sprintf("%s%s?symbol=%s", strings.TrimRight(baseURL, "/"), binanceTickerPath, url.QueryEscape(symbol)) + body, err := getJSON(ctx, httpClient, requestURL) + if err != nil { + return nil, err + } + + var resp binanceTickerResponse + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("failed to parse Binance JSON response: %w", err) + } + return parsePositiveFloat(resp.Price, symbol) +} + +type okxTickerResponse struct { + Code string `json:"code"` + Msg string `json:"msg"` + Data []okxTickerRecord `json:"data"` +} + +type okxTickerRecord struct { + InstID string `json:"instId"` + Last string `json:"last"` +} + +func fetchOKXPrice(ctx context.Context, httpClient *http.Client, baseURL string, symbol string) (*big.Float, error) { + requestURL := fmt.Sprintf("%s%s?instId=%s", strings.TrimRight(baseURL, "/"), okxTickerPath, url.QueryEscape(symbol)) + body, err := getJSON(ctx, httpClient, requestURL) + if err != nil { + return nil, err + } + + var resp okxTickerResponse + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("failed to parse OKX JSON response: %w", err) + } + if resp.Code != "0" { + return nil, fmt.Errorf("OKX API error: %s - %s", resp.Code, resp.Msg) + } + if len(resp.Data) == 0 { + return nil, fmt.Errorf("no OKX ticker data returned for %s", symbol) + } + return parsePositiveFloat(resp.Data[0].Last, symbol) +} + +func getJSON(ctx context.Context, httpClient *http.Client, requestURL string) ([]byte, error) { + return getJSONWithHeaders(ctx, httpClient, requestURL, nil) +} + +func getJSONWithHeaders(ctx context.Context, httpClient *http.Client, requestURL string, headers map[string]string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, "GET", requestURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + for name, value := range headers { + req.Header.Set(name, value) + } + + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("HTTP status %d: %s", resp.StatusCode, string(body)) + } + contentType := resp.Header.Get("Content-Type") + if contentType != "" && !strings.Contains(strings.ToLower(contentType), "json") { + return nil, fmt.Errorf("unexpected content type %q: %s", contentType, string(body)) + } + return body, nil +} + +func parsePositiveFloat(priceStr string, symbol string) (*big.Float, error) { + if priceStr == "" { + return nil, fmt.Errorf("no price data returned for symbol %s", symbol) + } + price, err := strconv.ParseFloat(priceStr, 64) + if err != nil { + return nil, fmt.Errorf("failed to parse price '%s': %w", priceStr, err) + } + if price <= 0 { + return nil, fmt.Errorf("price must be positive for symbol %s, got %s", symbol, priceStr) + } + return big.NewFloat(price), nil +} diff --git a/token-price-oracle/client/cex_feed_test.go b/token-price-oracle/client/cex_feed_test.go new file mode 100644 index 000000000..a6375a20c --- /dev/null +++ b/token-price-oracle/client/cex_feed_test.go @@ -0,0 +1,63 @@ +package client + +import ( + "context" + "math/big" + "net/http" + "net/http/httptest" + "testing" +) + +func TestFetchBinancePrice(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != binanceTickerPath { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.URL.Query().Get("symbol") != "BTCUSDT" { + t.Fatalf("unexpected symbol: %s", r.URL.Query().Get("symbol")) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"symbol":"BTCUSDT","price":"64385.12"}`)) + })) + defer server.Close() + + price, err := fetchBinancePrice(context.Background(), server.Client(), server.URL, "BTCUSDT") + if err != nil { + t.Fatal(err) + } + if price.Cmp(big.NewFloat(64385.12)) != 0 { + t.Fatalf("price = %s, want 64385.12", price.String()) + } +} + +func TestFetchOKXPrice(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != okxTickerPath { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.URL.Query().Get("instId") != "BTC-USDT" { + t.Fatalf("unexpected instId: %s", r.URL.Query().Get("instId")) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"code":"0","msg":"","data":[{"instId":"BTC-USDT","last":"64386.45"}]}`)) + })) + defer server.Close() + + price, err := fetchOKXPrice(context.Background(), server.Client(), server.URL, "BTC-USDT") + if err != nil { + t.Fatal(err) + } + if price.Cmp(big.NewFloat(64386.45)) != 0 { + t.Fatalf("price = %s, want 64386.45", price.String()) + } +} + +func TestParseFixedStablecoinPrice(t *testing.T) { + price, err := parseFixedStablecoinPrice("$1.0") + if err != nil { + t.Fatal(err) + } + if price.Cmp(big.NewFloat(1.0)) != 0 { + t.Fatalf("price = %s, want 1", price.String()) + } +} diff --git a/token-price-oracle/client/chainlink_feed.go b/token-price-oracle/client/chainlink_feed.go new file mode 100644 index 000000000..8b60dabdb --- /dev/null +++ b/token-price-oracle/client/chainlink_feed.go @@ -0,0 +1,265 @@ +package client + +import ( + "context" + "errors" + "fmt" + "math/big" + "strings" + "sync" + "time" + + "github.com/morph-l2/go-ethereum/accounts/abi" + "github.com/morph-l2/go-ethereum/accounts/abi/bind" + "github.com/morph-l2/go-ethereum/common" + "github.com/morph-l2/go-ethereum/ethclient" + "github.com/morph-l2/go-ethereum/log" +) + +const chainlinkAggregatorV3ABI = `[ + {"inputs":[],"name":"decimals","outputs":[{"internalType":"uint8","name":"","type":"uint8"}],"stateMutability":"view","type":"function"}, + {"inputs":[],"name":"latestRoundData","outputs":[{"internalType":"uint80","name":"roundId","type":"uint80"},{"internalType":"int256","name":"answer","type":"int256"},{"internalType":"uint256","name":"startedAt","type":"uint256"},{"internalType":"uint256","name":"updatedAt","type":"uint256"},{"internalType":"uint80","name":"answeredInRound","type":"uint80"}],"stateMutability":"view","type":"function"} +]` + +var parsedChainlinkAggregatorABI = mustParseChainlinkAggregatorABI() + +// ChainlinkPriceFeed reads Chainlink AggregatorV3 feeds over RPC. +type ChainlinkPriceFeed struct { + caller bind.ContractCaller + mu sync.RWMutex + tokenFeeds map[uint16]common.Address + ethUSDFeed common.Address + maxStaleness time.Duration + log log.Logger +} + +// NewChainlinkPriceFeed creates a Chainlink price feed using an RPC endpoint. +func NewChainlinkPriceFeed(tokenFeedMap map[uint16]string, rpcURL string, ethUSDFeed common.Address, maxStaleness time.Duration) (*ChainlinkPriceFeed, error) { + if rpcURL == "" { + return nil, fmt.Errorf("chainlink price feed requires --chainlink-rpc") + } + + caller, err := ethclient.Dial(rpcURL) + if err != nil { + return nil, fmt.Errorf("failed to connect chainlink rpc: %w", err) + } + + feed, err := NewChainlinkPriceFeedWithCaller(tokenFeedMap, caller, ethUSDFeed, maxStaleness) + if err != nil { + caller.Close() + return nil, err + } + return feed, nil +} + +// NewChainlinkPriceFeedWithCaller creates a Chainlink price feed with a caller. +// It is primarily useful for tests. +func NewChainlinkPriceFeedWithCaller(tokenFeedMap map[uint16]string, caller bind.ContractCaller, ethUSDFeed common.Address, maxStaleness time.Duration) (*ChainlinkPriceFeed, error) { + if caller == nil { + return nil, fmt.Errorf("chainlink price feed requires rpc caller") + } + if ethUSDFeed == (common.Address{}) { + return nil, fmt.Errorf("chainlink price feed requires --chainlink-eth-usd-feed") + } + if maxStaleness <= 0 { + return nil, fmt.Errorf("chainlink max staleness must be positive") + } + + feeds := make(map[uint16]common.Address, len(tokenFeedMap)) + for tokenID, feedAddr := range tokenFeedMap { + feedAddr = strings.TrimSpace(feedAddr) + if !common.IsHexAddress(feedAddr) { + return nil, fmt.Errorf("invalid chainlink feed address for token %d: %s", tokenID, feedAddr) + } + feeds[tokenID] = common.HexToAddress(feedAddr) + } + if len(feeds) == 0 { + return nil, fmt.Errorf("chainlink price feed requires token mapping, please configure --token-mapping-chainlink") + } + + return &ChainlinkPriceFeed{ + caller: caller, + tokenFeeds: feeds, + ethUSDFeed: ethUSDFeed, + maxStaleness: maxStaleness, + log: log.New("component", "chainlink_price_feed"), + }, nil +} + +// GetTokenPrice returns token price in USD from Chainlink. +func (c *ChainlinkPriceFeed) GetTokenPrice(ctx context.Context, tokenID uint16) (*TokenPrice, error) { + c.mu.RLock() + feedAddress, exists := c.tokenFeeds[tokenID] + ethUSDFeed := c.ethUSDFeed + c.mu.RUnlock() + + if !exists { + return nil, fmt.Errorf("token ID %d not mapped to Chainlink feed", tokenID) + } + + ethPrice, err := c.fetchFeedPrice(ctx, ethUSDFeed) + if err != nil { + return nil, fmt.Errorf("failed to fetch ETH/USD price from Chainlink: %w", err) + } + + tokenPrice, err := c.fetchFeedPrice(ctx, feedAddress) + if err != nil { + return nil, fmt.Errorf("failed to fetch token price from Chainlink for token %d: %w", tokenID, err) + } + + c.log.Info("Fetched price from Chainlink", + "source", "chainlink", + "token_id", tokenID, + "feed", feedAddress.Hex(), + "token_price_usd", tokenPrice.String(), + "eth_price_usd", ethPrice.String()) + + return &TokenPrice{ + TokenID: tokenID, + Symbol: feedAddress.Hex(), + TokenPriceUSD: tokenPrice, + EthPriceUSD: ethPrice, + }, nil +} + +// GetBatchTokenPrices returns token prices in USD for multiple tokens. +func (c *ChainlinkPriceFeed) GetBatchTokenPrices(ctx context.Context, tokenIDs []uint16) (map[uint16]*TokenPrice, error) { + ethPrice, err := c.fetchFeedPrice(ctx, c.ethUSDFeed) + if err != nil { + return nil, fmt.Errorf("failed to fetch ETH/USD price from Chainlink: %w", err) + } + + prices := make(map[uint16]*TokenPrice, len(tokenIDs)) + for _, tokenID := range tokenIDs { + c.mu.RLock() + feedAddress, exists := c.tokenFeeds[tokenID] + c.mu.RUnlock() + if !exists { + return nil, fmt.Errorf("token ID %d not mapped to Chainlink feed", tokenID) + } + + tokenPrice, err := c.fetchFeedPrice(ctx, feedAddress) + if err != nil { + return nil, fmt.Errorf("failed to fetch token price from Chainlink for token %d: %w", tokenID, err) + } + + prices[tokenID] = &TokenPrice{ + TokenID: tokenID, + Symbol: feedAddress.Hex(), + TokenPriceUSD: tokenPrice, + EthPriceUSD: new(big.Float).Copy(ethPrice), + } + } + + return prices, nil +} + +func (c *ChainlinkPriceFeed) fetchFeedPrice(ctx context.Context, feedAddress common.Address) (*big.Float, error) { + contract := bind.NewBoundContract(feedAddress, parsedChainlinkAggregatorABI, c.caller, nil, nil) + + var roundData []interface{} + if err := contract.Call(&bind.CallOpts{Context: ctx}, &roundData, "latestRoundData"); err != nil { + return nil, fmt.Errorf("latestRoundData call failed for feed %s: %w", feedAddress.Hex(), err) + } + + roundID, answer, updatedAt, answeredInRound, err := parseChainlinkRoundData(roundData) + if err != nil { + return nil, fmt.Errorf("invalid latestRoundData response for feed %s: %w", feedAddress.Hex(), err) + } + if err := validateChainlinkRound(answer, updatedAt, roundID, answeredInRound, c.maxStaleness, time.Now()); err != nil { + return nil, fmt.Errorf("invalid Chainlink round for feed %s: %w", feedAddress.Hex(), err) + } + + var decimalsOut []interface{} + if err := contract.Call(&bind.CallOpts{Context: ctx}, &decimalsOut, "decimals"); err != nil { + return nil, fmt.Errorf("decimals call failed for feed %s: %w", feedAddress.Hex(), err) + } + decimals, err := parseChainlinkDecimals(decimalsOut) + if err != nil { + return nil, fmt.Errorf("invalid decimals response for feed %s: %w", feedAddress.Hex(), err) + } + + return chainlinkAnswerToFloat(answer, decimals), nil +} + +func parseChainlinkRoundData(values []interface{}) (roundID, answer, updatedAt, answeredInRound *big.Int, err error) { + if len(values) != 5 { + return nil, nil, nil, nil, fmt.Errorf("expected 5 values, got %d", len(values)) + } + + roundID, ok := values[0].(*big.Int) + if !ok { + return nil, nil, nil, nil, errors.New("roundId is not *big.Int") + } + answer, ok = values[1].(*big.Int) + if !ok { + return nil, nil, nil, nil, errors.New("answer is not *big.Int") + } + updatedAt, ok = values[3].(*big.Int) + if !ok { + return nil, nil, nil, nil, errors.New("updatedAt is not *big.Int") + } + answeredInRound, ok = values[4].(*big.Int) + if !ok { + return nil, nil, nil, nil, errors.New("answeredInRound is not *big.Int") + } + + return roundID, answer, updatedAt, answeredInRound, nil +} + +func parseChainlinkDecimals(values []interface{}) (uint8, error) { + if len(values) != 1 { + return 0, fmt.Errorf("expected 1 value, got %d", len(values)) + } + + switch decimals := values[0].(type) { + case uint8: + return decimals, nil + case *big.Int: + if !decimals.IsUint64() || decimals.Uint64() > 255 { + return 0, fmt.Errorf("decimals out of uint8 range: %s", decimals.String()) + } + return uint8(decimals.Uint64()), nil + default: + return 0, fmt.Errorf("decimals has unexpected type %T", values[0]) + } +} + +func validateChainlinkRound(answer, updatedAt, roundID, answeredInRound *big.Int, maxStaleness time.Duration, now time.Time) error { + if answer == nil || updatedAt == nil || roundID == nil || answeredInRound == nil { + return errors.New("round data contains nil value") + } + if answer.Sign() <= 0 { + return fmt.Errorf("answer must be positive, got %s", answer.String()) + } + if updatedAt.Sign() <= 0 { + return errors.New("updatedAt must be positive") + } + if answeredInRound.Cmp(roundID) < 0 { + return fmt.Errorf("answeredInRound %s is older than roundId %s", answeredInRound.String(), roundID.String()) + } + + updated := time.Unix(updatedAt.Int64(), 0) + if updated.After(now.Add(maxStaleness)) { + return fmt.Errorf("updatedAt %s is too far in the future", updated.UTC().Format(time.RFC3339)) + } + if now.Sub(updated) > maxStaleness { + return fmt.Errorf("price is stale: updatedAt=%s maxStaleness=%s", updated.UTC().Format(time.RFC3339), maxStaleness) + } + + return nil +} + +func chainlinkAnswerToFloat(answer *big.Int, decimals uint8) *big.Float { + price := new(big.Float).SetPrec(256).SetInt(answer) + scale := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(decimals)), nil) + return price.Quo(price, new(big.Float).SetPrec(256).SetInt(scale)) +} + +func mustParseChainlinkAggregatorABI() abi.ABI { + parsed, err := abi.JSON(strings.NewReader(chainlinkAggregatorV3ABI)) + if err != nil { + panic(err) + } + return parsed +} diff --git a/token-price-oracle/client/chainlink_feed_test.go b/token-price-oracle/client/chainlink_feed_test.go new file mode 100644 index 000000000..f8fa01a38 --- /dev/null +++ b/token-price-oracle/client/chainlink_feed_test.go @@ -0,0 +1,69 @@ +package client + +import ( + "math/big" + "testing" + "time" +) + +func TestValidateChainlinkRound(t *testing.T) { + now := time.Unix(1_700_000_000, 0) + + tests := []struct { + name string + answer *big.Int + updatedAt *big.Int + roundID *big.Int + answeredInRound *big.Int + wantErr bool + }{ + { + name: "valid", + answer: big.NewInt(2000_00000000), + updatedAt: big.NewInt(now.Add(-5 * time.Minute).Unix()), + roundID: big.NewInt(10), + answeredInRound: big.NewInt(10), + }, + { + name: "non-positive answer", + answer: big.NewInt(0), + updatedAt: big.NewInt(now.Add(-5 * time.Minute).Unix()), + roundID: big.NewInt(10), + answeredInRound: big.NewInt(10), + wantErr: true, + }, + { + name: "stale", + answer: big.NewInt(2000_00000000), + updatedAt: big.NewInt(now.Add(-2 * time.Hour).Unix()), + roundID: big.NewInt(10), + answeredInRound: big.NewInt(10), + wantErr: true, + }, + { + name: "answered in old round", + answer: big.NewInt(2000_00000000), + updatedAt: big.NewInt(now.Add(-5 * time.Minute).Unix()), + roundID: big.NewInt(10), + answeredInRound: big.NewInt(9), + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateChainlinkRound(tt.answer, tt.updatedAt, tt.roundID, tt.answeredInRound, time.Hour, now) + if (err != nil) != tt.wantErr { + t.Fatalf("validateChainlinkRound() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestChainlinkAnswerToFloat(t *testing.T) { + price := chainlinkAnswerToFloat(big.NewInt(123456789000), 8) + got, _ := price.Float64() + if got != 1234.56789 { + t.Fatalf("chainlinkAnswerToFloat() = %v, want 1234.56789", got) + } +} diff --git a/token-price-oracle/client/price_feed.go b/token-price-oracle/client/price_feed.go index b689f34e1..c138691da 100644 --- a/token-price-oracle/client/price_feed.go +++ b/token-price-oracle/client/price_feed.go @@ -100,7 +100,16 @@ func (f *FallbackPriceFeed) GetBatchTokenPrices(ctx context.Context, tokenIDs [] if err == nil { // Validate all returned prices to prevent nil pointer panics hasInvalidPrice := false - for tokenID, price := range prices { + for _, tokenID := range tokenIDs { + price, exists := prices[tokenID] + if !exists { + f.log.Warn("Feed did not return price for requested token, treating as failure", + "token_id", tokenID, + "feed", feedName, + "priority", i) + hasInvalidPrice = true + break + } if price == nil || price.TokenPriceUSD == nil || price.EthPriceUSD == nil { f.log.Warn("Feed returned nil price or components for token, treating as failure", "token_id", tokenID, @@ -134,4 +143,3 @@ func (f *FallbackPriceFeed) GetBatchTokenPrices(ctx context.Context, tokenIDs [] return nil, lastErr } - diff --git a/token-price-oracle/client/pyth_feed.go b/token-price-oracle/client/pyth_feed.go new file mode 100644 index 000000000..e6ed29be7 --- /dev/null +++ b/token-price-oracle/client/pyth_feed.go @@ -0,0 +1,281 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" + "math" + "math/big" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/morph-l2/go-ethereum/log" +) + +const pythLatestPricePath = "/v2/updates/price/latest" + +// PythHermesPriceFeed reads Pyth prices from Hermes as an off-chain data source. +type PythHermesPriceFeed struct { + httpClient *http.Client + mu sync.RWMutex + tokenPriceIDs map[uint16]string + ethUSDPriceID string + maxStaleness time.Duration + maxConfidenceBPS uint64 + baseURL string + apiKey string + log log.Logger +} + +// NewPythHermesPriceFeed creates a Pyth Hermes price feed. +func NewPythHermesPriceFeed(tokenPriceIDs map[uint16]string, baseURL string, apiKey string, ethUSDPriceID string, maxStaleness time.Duration, maxConfidenceBPS uint64) (*PythHermesPriceFeed, error) { + ethUSDPriceID = normalizePythPriceID(ethUSDPriceID) + if ethUSDPriceID == "" { + return nil, fmt.Errorf("pyth price feed requires --pyth-eth-usd-price-id") + } + if maxStaleness <= 0 { + return nil, fmt.Errorf("pyth max staleness must be positive") + } + + normalized := make(map[uint16]string, len(tokenPriceIDs)) + for tokenID, priceID := range tokenPriceIDs { + priceID = normalizePythPriceID(priceID) + if priceID == "" { + return nil, fmt.Errorf("invalid pyth price ID for token %d", tokenID) + } + normalized[tokenID] = priceID + } + if len(normalized) == 0 { + return nil, fmt.Errorf("pyth price feed requires token mapping, please configure --token-mapping-pyth") + } + + return &PythHermesPriceFeed{ + httpClient: &http.Client{Timeout: 10 * time.Second}, + tokenPriceIDs: normalized, + ethUSDPriceID: ethUSDPriceID, + maxStaleness: maxStaleness, + maxConfidenceBPS: maxConfidenceBPS, + baseURL: strings.TrimRight(baseURL, "/"), + apiKey: strings.TrimSpace(apiKey), + log: log.New("component", "pyth_price_feed"), + }, nil +} + +// GetTokenPrice returns token price in USD from Pyth Hermes. +func (p *PythHermesPriceFeed) GetTokenPrice(ctx context.Context, tokenID uint16) (*TokenPrice, error) { + p.mu.RLock() + priceID, exists := p.tokenPriceIDs[tokenID] + ethUSDPriceID := p.ethUSDPriceID + p.mu.RUnlock() + + if !exists { + return nil, fmt.Errorf("token ID %d not mapped to Pyth price ID", tokenID) + } + + priceMap, err := p.fetchPrices(ctx, []string{ethUSDPriceID, priceID}) + if err != nil { + return nil, err + } + + ethPrice, err := pythPriceToFloat(priceMap[ethUSDPriceID]) + if err != nil { + return nil, fmt.Errorf("failed to convert ETH/USD Pyth price: %w", err) + } + tokenPrice, err := pythPriceToFloat(priceMap[priceID]) + if err != nil { + return nil, fmt.Errorf("failed to convert token Pyth price for token %d: %w", tokenID, err) + } + + p.log.Info("Fetched price from Pyth", + "source", "pyth", + "token_id", tokenID, + "price_id", priceID, + "token_price_usd", tokenPrice.String(), + "eth_price_usd", ethPrice.String()) + + return &TokenPrice{ + TokenID: tokenID, + Symbol: priceID, + TokenPriceUSD: tokenPrice, + EthPriceUSD: ethPrice, + }, nil +} + +// GetBatchTokenPrices returns token prices in USD for multiple tokens. +func (p *PythHermesPriceFeed) GetBatchTokenPrices(ctx context.Context, tokenIDs []uint16) (map[uint16]*TokenPrice, error) { + p.mu.RLock() + priceIDs := make([]string, 0, len(tokenIDs)+1) + priceIDs = append(priceIDs, p.ethUSDPriceID) + tokenPriceIDs := make(map[uint16]string, len(tokenIDs)) + for _, tokenID := range tokenIDs { + priceID, exists := p.tokenPriceIDs[tokenID] + if !exists { + p.mu.RUnlock() + return nil, fmt.Errorf("token ID %d not mapped to Pyth price ID", tokenID) + } + tokenPriceIDs[tokenID] = priceID + priceIDs = append(priceIDs, priceID) + } + p.mu.RUnlock() + + priceMap, err := p.fetchPrices(ctx, priceIDs) + if err != nil { + return nil, err + } + + ethPrice, err := pythPriceToFloat(priceMap[p.ethUSDPriceID]) + if err != nil { + return nil, fmt.Errorf("failed to convert ETH/USD Pyth price: %w", err) + } + + prices := make(map[uint16]*TokenPrice, len(tokenIDs)) + for _, tokenID := range tokenIDs { + priceID := tokenPriceIDs[tokenID] + tokenPrice, err := pythPriceToFloat(priceMap[priceID]) + if err != nil { + return nil, fmt.Errorf("failed to convert token Pyth price for token %d: %w", tokenID, err) + } + prices[tokenID] = &TokenPrice{ + TokenID: tokenID, + Symbol: priceID, + TokenPriceUSD: tokenPrice, + EthPriceUSD: new(big.Float).Copy(ethPrice), + } + } + + return prices, nil +} + +func (p *PythHermesPriceFeed) fetchPrices(ctx context.Context, priceIDs []string) (map[string]pythPrice, error) { + values := url.Values{} + values.Set("parsed", "true") + values.Set("encoding", "hex") + seen := make(map[string]struct{}, len(priceIDs)) + for _, priceID := range priceIDs { + priceID = normalizePythPriceID(priceID) + if _, exists := seen[priceID]; exists { + continue + } + seen[priceID] = struct{}{} + values.Add("ids[]", priceID) + } + + requestURL := fmt.Sprintf("%s%s?%s", p.baseURL, pythLatestPricePath, values.Encode()) + headers := map[string]string{"Accept": "application/json"} + if p.apiKey != "" { + headers["Authorization"] = "Bearer " + p.apiKey + } + body, err := getJSONWithHeaders(ctx, p.httpClient, requestURL, headers) + if err != nil { + return nil, err + } + + var resp pythLatestPriceResponse + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("failed to parse Pyth Hermes JSON response: %w", err) + } + + priceMap := make(map[string]pythPrice, len(resp.Parsed)) + now := time.Now() + for _, parsed := range resp.Parsed { + priceID := normalizePythPriceID(parsed.ID) + if err := validatePythPrice(parsed.Price, p.maxStaleness, p.maxConfidenceBPS, now); err != nil { + return nil, fmt.Errorf("invalid Pyth price for %s: %w", priceID, err) + } + priceMap[priceID] = parsed.Price + } + + for priceID := range seen { + if _, exists := priceMap[priceID]; !exists { + return nil, fmt.Errorf("Pyth response missing price ID %s", priceID) + } + } + + return priceMap, nil +} + +type pythLatestPriceResponse struct { + Parsed []pythParsedPrice `json:"parsed"` +} + +type pythParsedPrice struct { + ID string `json:"id"` + Price pythPrice `json:"price"` +} + +type pythPrice struct { + Price string `json:"price"` + Confidence string `json:"conf"` + Exponent int32 `json:"expo"` + PublishTime int64 `json:"publish_time"` +} + +func validatePythPrice(price pythPrice, maxStaleness time.Duration, maxConfidenceBPS uint64, now time.Time) error { + priceInt, ok := new(big.Int).SetString(price.Price, 10) + if !ok { + return fmt.Errorf("invalid price integer %q", price.Price) + } + if priceInt.Sign() <= 0 { + return fmt.Errorf("price must be positive, got %s", price.Price) + } + + confInt, ok := new(big.Int).SetString(price.Confidence, 10) + if !ok { + return fmt.Errorf("invalid confidence integer %q", price.Confidence) + } + if confInt.Sign() < 0 { + return fmt.Errorf("confidence must be non-negative, got %s", price.Confidence) + } + + published := time.Unix(price.PublishTime, 0) + if price.PublishTime <= 0 { + return fmt.Errorf("publish_time must be positive") + } + if published.After(now.Add(maxStaleness)) { + return fmt.Errorf("publish_time %s is too far in the future", published.UTC().Format(time.RFC3339)) + } + if now.Sub(published) > maxStaleness { + return fmt.Errorf("price is stale: publish_time=%s maxStaleness=%s", published.UTC().Format(time.RFC3339), maxStaleness) + } + + if maxConfidenceBPS > 0 { + confBPS := new(big.Int).Mul(confInt, big.NewInt(10000)) + maxAllowed := new(big.Int).Mul(priceInt, new(big.Int).SetUint64(maxConfidenceBPS)) + if confBPS.Cmp(maxAllowed) > 0 { + return fmt.Errorf("confidence too wide: conf=%s price=%s max_bps=%d", price.Confidence, price.Price, maxConfidenceBPS) + } + } + + return nil +} + +func pythPriceToFloat(price pythPrice) (*big.Float, error) { + priceInt, ok := new(big.Int).SetString(price.Price, 10) + if !ok { + return nil, fmt.Errorf("invalid price integer %q", price.Price) + } + + value := new(big.Float).SetPrec(256).SetInt(priceInt) + if price.Exponent == 0 { + return value, nil + } + + exponent := int64(price.Exponent) + if exponent > 0 { + if exponent > math.MaxInt32 { + return nil, fmt.Errorf("pyth exponent too large: %d", exponent) + } + scale := new(big.Int).Exp(big.NewInt(10), big.NewInt(exponent), nil) + return value.Mul(value, new(big.Float).SetPrec(256).SetInt(scale)), nil + } + + scale := new(big.Int).Exp(big.NewInt(10), big.NewInt(-exponent), nil) + return value.Quo(value, new(big.Float).SetPrec(256).SetInt(scale)), nil +} + +func normalizePythPriceID(priceID string) string { + return strings.ToLower(strings.TrimPrefix(strings.TrimSpace(priceID), "0x")) +} diff --git a/token-price-oracle/client/pyth_feed_test.go b/token-price-oracle/client/pyth_feed_test.go new file mode 100644 index 000000000..d36eab5d6 --- /dev/null +++ b/token-price-oracle/client/pyth_feed_test.go @@ -0,0 +1,82 @@ +package client + +import ( + "math/big" + "testing" + "time" +) + +func TestValidatePythPrice(t *testing.T) { + now := time.Unix(1_700_000_000, 0) + + tests := []struct { + name string + price pythPrice + maxConfidenceBPS uint64 + wantErr bool + }{ + { + name: "valid", + price: pythPrice{ + Price: "175500000000", + Confidence: "100000000", + Exponent: -8, + PublishTime: now.Add(-5 * time.Minute).Unix(), + }, + maxConfidenceBPS: 100, + }, + { + name: "stale", + price: pythPrice{ + Price: "175500000000", + Confidence: "100000000", + Exponent: -8, + PublishTime: now.Add(-2 * time.Hour).Unix(), + }, + maxConfidenceBPS: 100, + wantErr: true, + }, + { + name: "too wide confidence", + price: pythPrice{ + Price: "100000000", + Confidence: "2000000", + Exponent: -8, + PublishTime: now.Add(-5 * time.Minute).Unix(), + }, + maxConfidenceBPS: 100, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validatePythPrice(tt.price, time.Hour, tt.maxConfidenceBPS, now) + if (err != nil) != tt.wantErr { + t.Fatalf("validatePythPrice() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestPythPriceToFloat(t *testing.T) { + price, err := pythPriceToFloat(pythPrice{ + Price: "175500000000", + Exponent: -8, + }) + if err != nil { + t.Fatal(err) + } + + want := big.NewFloat(1755) + if price.Cmp(want) != 0 { + t.Fatalf("pythPriceToFloat() = %s, want %s", price.String(), want.String()) + } +} + +func TestNormalizePythPriceID(t *testing.T) { + got := normalizePythPriceID(" 0xAbC123 ") + if got != "abc123" { + t.Fatalf("normalizePythPriceID() = %q, want abc123", got) + } +} diff --git a/token-price-oracle/config/config.go b/token-price-oracle/config/config.go index ef0923326..2b11373d5 100644 --- a/token-price-oracle/config/config.go +++ b/token-price-oracle/config/config.go @@ -21,15 +21,21 @@ const ( type PriceFeedType string const ( - PriceFeedTypeBitget PriceFeedType = "bitget" - PriceFeedTypeBinance PriceFeedType = "binance" + PriceFeedTypeBitget PriceFeedType = "bitget" + PriceFeedTypeBinance PriceFeedType = "binance" + PriceFeedTypeChainlink PriceFeedType = "chainlink" + PriceFeedTypeOKX PriceFeedType = "okx" + PriceFeedTypePyth PriceFeedType = "pyth" ) // ValidPriceFeedTypes returns all valid price feed types func ValidPriceFeedTypes() []PriceFeedType { return []PriceFeedType{ + PriceFeedTypeChainlink, + PriceFeedTypePyth, PriceFeedTypeBitget, - // PriceFeedTypeBinance, // TODO: Add back when Binance price feed is implemented + PriceFeedTypeBinance, + PriceFeedTypeOKX, } } @@ -58,12 +64,21 @@ type Config struct { // Private key PrivateKey string // Price update parameters - PriceUpdateInterval time.Duration // Price update interval - PriceThreshold uint64 // Price change threshold percentage to trigger update - PriceFeedPriority []PriceFeedType // Price feed types in priority order (fallback mechanism) - TokenMappings map[PriceFeedType]map[uint16]string // Token ID to trading pair mappings for each price feed type - BitgetAPIBaseURL string // Bitget API base URL - BinanceAPIBaseURL string // Binance API base URL + PriceUpdateInterval time.Duration // Price update interval + PriceThreshold uint64 // Price change threshold percentage to trigger update + PriceFeedPriority []PriceFeedType // Price feed types in priority order (fallback mechanism) + TokenMappings map[PriceFeedType]map[uint16]string // Token ID to trading pair mappings for each price feed type + BitgetAPIBaseURL string // Bitget API base URL + BinanceAPIBaseURL string // Binance API base URL + OKXAPIBaseURL string // OKX API base URL + ChainlinkRPC string // RPC URL used for Chainlink feeds + ChainlinkETHUSDFeed common.Address // ETH/USD AggregatorV3 feed address + ChainlinkMaxStaleness time.Duration // Maximum accepted age for Chainlink feed rounds + PythHermesBaseURL string // Pyth Hermes API base URL + PythAPIKey string // Optional Pyth Hermes API key + PythETHUSDPriceID string // Pyth ETH/USD price ID + PythMaxStaleness time.Duration // Maximum accepted age for Pyth prices + PythMaxConfidenceBPS uint64 // Maximum accepted Pyth confidence interval in BPS (0 disables) // External sign ExternalSign bool @@ -141,7 +156,7 @@ func LoadConfig(ctx *cli.Context) (*Config, error) { // Validate price threshold is reasonable (basis points should be 0-MaxPriceThresholdBPS) if cfg.PriceThreshold > MaxPriceThresholdBPS { - return nil, fmt.Errorf("price threshold %d is too large (should be 0-%d basis points, where %d bps = 100%%)", + return nil, fmt.Errorf("price threshold %d is too large (should be 0-%d basis points, where %d bps = 100%%)", cfg.PriceThreshold, MaxPriceThresholdBPS, MaxPriceThresholdBPS) } @@ -224,13 +239,80 @@ func LoadConfig(ctx *cli.Context) (*Config, error) { cfg.TokenMappings[PriceFeedTypeBinance] = binanceMapping } + okxMapping, err := parseTokenMapping(ctx.String(flags.TokenMappingOKXFlag.Name)) + if err != nil { + return nil, fmt.Errorf("failed to parse okx token mapping: %w", err) + } + if len(okxMapping) > 0 { + cfg.TokenMappings[PriceFeedTypeOKX] = okxMapping + } + + chainlinkMapping, err := parseTokenMapping(ctx.String(flags.TokenMappingChainlinkFlag.Name)) + if err != nil { + return nil, fmt.Errorf("failed to parse chainlink token mapping: %w", err) + } + if len(chainlinkMapping) > 0 { + cfg.TokenMappings[PriceFeedTypeChainlink] = chainlinkMapping + } + + pythMapping, err := parseTokenMapping(ctx.String(flags.TokenMappingPythFlag.Name)) + if err != nil { + return nil, fmt.Errorf("failed to parse pyth token mapping: %w", err) + } + if len(pythMapping) > 0 { + cfg.TokenMappings[PriceFeedTypePyth] = pythMapping + } + // Parse API base URLs cfg.BitgetAPIBaseURL = ctx.String(flags.BitgetAPIBaseURLFlag.Name) cfg.BinanceAPIBaseURL = ctx.String(flags.BinanceAPIBaseURLFlag.Name) + cfg.OKXAPIBaseURL = ctx.String(flags.OKXAPIBaseURLFlag.Name) + cfg.ChainlinkRPC = ctx.String(flags.ChainlinkRPCFlag.Name) + cfg.ChainlinkMaxStaleness = ctx.Duration(flags.ChainlinkMaxStalenessFlag.Name) + cfg.PythHermesBaseURL = ctx.String(flags.PythHermesBaseURLFlag.Name) + cfg.PythAPIKey = strings.TrimSpace(ctx.String(flags.PythAPIKeyFlag.Name)) + cfg.PythETHUSDPriceID = strings.TrimSpace(ctx.String(flags.PythETHUSDPriceIDFlag.Name)) + cfg.PythMaxStaleness = ctx.Duration(flags.PythMaxStalenessFlag.Name) + cfg.PythMaxConfidenceBPS = ctx.Uint64(flags.PythMaxConfidenceBPSFlag.Name) + chainlinkETHUSDFeed := strings.TrimSpace(ctx.String(flags.ChainlinkETHUSDFeedFlag.Name)) + if chainlinkETHUSDFeed != "" { + if !common.IsHexAddress(chainlinkETHUSDFeed) { + return nil, fmt.Errorf("invalid chainlink ETH/USD feed address: %s", chainlinkETHUSDFeed) + } + cfg.ChainlinkETHUSDFeed = common.HexToAddress(chainlinkETHUSDFeed) + } // Validate API URLs for configured feeds (non-empty check only) for _, feedType := range cfg.PriceFeedPriority { switch feedType { + case PriceFeedTypeChainlink: + if cfg.ChainlinkRPC == "" { + return nil, fmt.Errorf("chainlink feed is configured but --chainlink-rpc is not set") + } + if cfg.ChainlinkETHUSDFeed == (common.Address{}) { + return nil, fmt.Errorf("chainlink feed is configured but --chainlink-eth-usd-feed is not set") + } + if cfg.ChainlinkMaxStaleness <= 0 { + return nil, fmt.Errorf("chainlink max staleness must be positive") + } + if len(cfg.TokenMappings[PriceFeedTypeChainlink]) == 0 { + return nil, fmt.Errorf("chainlink feed is configured but --token-mapping-chainlink is not set") + } + + case PriceFeedTypePyth: + if cfg.PythHermesBaseURL == "" { + return nil, fmt.Errorf("pyth feed is configured but --pyth-hermes-base-url is not set") + } + if cfg.PythETHUSDPriceID == "" { + return nil, fmt.Errorf("pyth feed is configured but --pyth-eth-usd-price-id is not set") + } + if cfg.PythMaxStaleness <= 0 { + return nil, fmt.Errorf("pyth max staleness must be positive") + } + if len(cfg.TokenMappings[PriceFeedTypePyth]) == 0 { + return nil, fmt.Errorf("pyth feed is configured but --token-mapping-pyth is not set") + } + case PriceFeedTypeBitget: if cfg.BitgetAPIBaseURL == "" { return nil, fmt.Errorf("bitget feed is configured but --bitget-api-base-url is not set") @@ -240,6 +322,17 @@ func LoadConfig(ctx *cli.Context) (*Config, error) { if cfg.BinanceAPIBaseURL == "" { return nil, fmt.Errorf("binance feed is configured but --binance-api-base-url is not set") } + if len(cfg.TokenMappings[PriceFeedTypeBinance]) == 0 { + return nil, fmt.Errorf("binance feed is configured but --token-mapping-binance is not set") + } + + case PriceFeedTypeOKX: + if cfg.OKXAPIBaseURL == "" { + return nil, fmt.Errorf("okx feed is configured but --okx-api-base-url is not set") + } + if len(cfg.TokenMappings[PriceFeedTypeOKX]) == 0 { + return nil, fmt.Errorf("okx feed is configured but --token-mapping-okx is not set") + } } } diff --git a/token-price-oracle/docker-compose.yml b/token-price-oracle/docker-compose.yml index 389f0945e..0b828936c 100644 --- a/token-price-oracle/docker-compose.yml +++ b/token-price-oracle/docker-compose.yml @@ -16,12 +16,26 @@ services: # Price update configuration TOKEN_PRICE_ORACLE_PRICE_UPDATE_INTERVAL: ${TOKEN_PRICE_ORACLE_PRICE_UPDATE_INTERVAL:-30s} - TOKEN_PRICE_ORACLE_PRICE_THRESHOLD: ${TOKEN_PRICE_ORACLE_PRICE_THRESHOLD:-5} # percentage (%) + TOKEN_PRICE_ORACLE_PRICE_THRESHOLD: ${TOKEN_PRICE_ORACLE_PRICE_THRESHOLD:-5} # basis points (bps) # Price feed configuration TOKEN_PRICE_ORACLE_PRICE_FEED_PRIORITY: ${TOKEN_PRICE_ORACLE_PRICE_FEED_PRIORITY:-bitget} + TOKEN_PRICE_ORACLE_CHAINLINK_RPC: ${TOKEN_PRICE_ORACLE_CHAINLINK_RPC} + TOKEN_PRICE_ORACLE_CHAINLINK_ETH_USD_FEED: ${TOKEN_PRICE_ORACLE_CHAINLINK_ETH_USD_FEED} + TOKEN_PRICE_ORACLE_CHAINLINK_MAX_STALENESS: ${TOKEN_PRICE_ORACLE_CHAINLINK_MAX_STALENESS:-1h} + TOKEN_PRICE_ORACLE_TOKEN_MAPPING_CHAINLINK: ${TOKEN_PRICE_ORACLE_TOKEN_MAPPING_CHAINLINK} + TOKEN_PRICE_ORACLE_PYTH_HERMES_BASE_URL: ${TOKEN_PRICE_ORACLE_PYTH_HERMES_BASE_URL:-https://hermes.pyth.network} + TOKEN_PRICE_ORACLE_PYTH_API_KEY: ${TOKEN_PRICE_ORACLE_PYTH_API_KEY} + TOKEN_PRICE_ORACLE_PYTH_ETH_USD_PRICE_ID: ${TOKEN_PRICE_ORACLE_PYTH_ETH_USD_PRICE_ID} + TOKEN_PRICE_ORACLE_PYTH_MAX_STALENESS: ${TOKEN_PRICE_ORACLE_PYTH_MAX_STALENESS:-1h} + TOKEN_PRICE_ORACLE_PYTH_MAX_CONFIDENCE_BPS: ${TOKEN_PRICE_ORACLE_PYTH_MAX_CONFIDENCE_BPS:-0} + TOKEN_PRICE_ORACLE_TOKEN_MAPPING_PYTH: ${TOKEN_PRICE_ORACLE_TOKEN_MAPPING_PYTH} TOKEN_PRICE_ORACLE_TOKEN_MAPPING_BITGET: ${TOKEN_PRICE_ORACLE_TOKEN_MAPPING_BITGET} + TOKEN_PRICE_ORACLE_BITGET_API_BASE_URL: ${TOKEN_PRICE_ORACLE_BITGET_API_BASE_URL} TOKEN_PRICE_ORACLE_TOKEN_MAPPING_BINANCE: ${TOKEN_PRICE_ORACLE_TOKEN_MAPPING_BINANCE} + TOKEN_PRICE_ORACLE_BINANCE_API_BASE_URL: ${TOKEN_PRICE_ORACLE_BINANCE_API_BASE_URL:-https://api.binance.com} + TOKEN_PRICE_ORACLE_TOKEN_MAPPING_OKX: ${TOKEN_PRICE_ORACLE_TOKEN_MAPPING_OKX} + TOKEN_PRICE_ORACLE_OKX_API_BASE_URL: ${TOKEN_PRICE_ORACLE_OKX_API_BASE_URL:-https://www.okx.com} # Token IDs to monitor (optional, will fetch from contract if not set) TOKEN_PRICE_ORACLE_TOKEN_IDS: ${TOKEN_PRICE_ORACLE_TOKEN_IDS} diff --git a/token-price-oracle/env.example b/token-price-oracle/env.example index aadec144d..197b9de04 100644 --- a/token-price-oracle/env.example +++ b/token-price-oracle/env.example @@ -14,9 +14,26 @@ TOKEN_PRICE_ORACLE_PRIVATE_KEY=ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efca TOKEN_PRICE_ORACLE_PRICE_UPDATE_INTERVAL=30s TOKEN_PRICE_ORACLE_PRICE_THRESHOLD=100 # basis points (bps), e.g. 100 means 1% (100 bps), 10 means 0.1%, 1 means 0.01% -# Price feed priority (comma-separated: bitget,binance) +# Price feed priority (comma-separated: chainlink,pyth,bitget,binance,okx) TOKEN_PRICE_ORACLE_PRICE_FEED_PRIORITY=bitget +# Chainlink feed configuration (optional) +# Feed addresses are AggregatorV3-compatible token/USD feeds. +# TOKEN_PRICE_ORACLE_PRICE_FEED_PRIORITY=chainlink,pyth,bitget,binance,okx +# TOKEN_PRICE_ORACLE_CHAINLINK_RPC=https://ethereum-rpc.publicnode.com +# TOKEN_PRICE_ORACLE_CHAINLINK_ETH_USD_FEED=0x... +# TOKEN_PRICE_ORACLE_CHAINLINK_MAX_STALENESS=1h +# TOKEN_PRICE_ORACLE_TOKEN_MAPPING_CHAINLINK=1:0x...,2:0x... + +# Pyth Hermes feed configuration (optional) +# Price IDs are token/USD feeds. 0x prefix is optional. +# TOKEN_PRICE_ORACLE_PYTH_HERMES_BASE_URL=https://hermes.pyth.network +# TOKEN_PRICE_ORACLE_PYTH_API_KEY= +# TOKEN_PRICE_ORACLE_PYTH_ETH_USD_PRICE_ID=0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace +# TOKEN_PRICE_ORACLE_PYTH_MAX_STALENESS=1h +# TOKEN_PRICE_ORACLE_PYTH_MAX_CONFIDENCE_BPS=0 +# TOKEN_PRICE_ORACLE_TOKEN_MAPPING_PYTH=1:0x...,2:0x... + # Token mapping for Bitget (tokenID:tradingPair,tokenID:tradingPair) # Format: # - Regular tokens: tokenID:SYMBOL (e.g., 1:BGBUSDT, 2:BTCUSDT) @@ -25,11 +42,15 @@ TOKEN_PRICE_ORACLE_PRICE_FEED_PRIORITY=bitget TOKEN_PRICE_ORACLE_TOKEN_MAPPING_BITGET=1:BGBUSDT,2:BTCUSDT,3:$1.0 # Token mapping for Binance (optional, same format as Bitget) -# TOKEN_PRICE_ORACLE_TOKEN_MAPPING_BINANCE=1:BGBUSDT,2:BTCUSDT,3:$1.0 +# TOKEN_PRICE_ORACLE_TOKEN_MAPPING_BINANCE=1:BNBUSDT,2:BTCUSDT,3:$1.0 + +# Token mapping for OKX (optional, OKX uses dash-separated instrument IDs) +# TOKEN_PRICE_ORACLE_TOKEN_MAPPING_OKX=1:BTC-USDT,2:ETH-USDT,3:$1.0 # API base URLs (optional, defaults provided) TOKEN_PRICE_ORACLE_BITGET_API_BASE_URL=https://api.bitget.com # TOKEN_PRICE_ORACLE_BINANCE_API_BASE_URL=https://api.binance.com +# TOKEN_PRICE_ORACLE_OKX_API_BASE_URL=https://www.okx.com # Token IDs to monitor (optional, will fetch from contract if not set) TOKEN_PRICE_ORACLE_TOKEN_IDS=1,2 diff --git a/token-price-oracle/flags/flags.go b/token-price-oracle/flags/flags.go index 1692806b7..8e199fde5 100644 --- a/token-price-oracle/flags/flags.go +++ b/token-price-oracle/flags/flags.go @@ -52,7 +52,7 @@ var ( PriceFeedPriorityFlag = cli.StringFlag{ Name: "price-feed-priority", - Usage: "Comma-separated list of price feed types in priority order (e.g. \"bitget,binance\")", + Usage: "Comma-separated list of price feed types in priority order (e.g. \"chainlink,pyth,bitget,binance,okx\")", Value: "bitget", EnvVar: prefixEnvVar("PRICE_FEED_PRIORITY"), } @@ -71,6 +71,27 @@ var ( EnvVar: prefixEnvVar("TOKEN_MAPPING_BINANCE"), } + TokenMappingOKXFlag = cli.StringFlag{ + Name: "token-mapping-okx", + Usage: "Token ID to OKX instrument mapping (e.g. \"1:BTC-USDT,2:ETH-USDT\")", + Value: "", + EnvVar: prefixEnvVar("TOKEN_MAPPING_OKX"), + } + + TokenMappingChainlinkFlag = cli.StringFlag{ + Name: "token-mapping-chainlink", + Usage: "Token ID to Chainlink AggregatorV3 feed address mapping (e.g. \"1:0x...,2:0x...\")", + Value: "", + EnvVar: prefixEnvVar("TOKEN_MAPPING_CHAINLINK"), + } + + TokenMappingPythFlag = cli.StringFlag{ + Name: "token-mapping-pyth", + Usage: "Token ID to Pyth price ID mapping (e.g. \"1:0x...,2:0x...\")", + Value: "", + EnvVar: prefixEnvVar("TOKEN_MAPPING_PYTH"), + } + BitgetAPIBaseURLFlag = cli.StringFlag{ Name: "bitget-api-base-url", Usage: "Bitget API base URL (required if bitget feed is enabled)", @@ -81,10 +102,73 @@ var ( BinanceAPIBaseURLFlag = cli.StringFlag{ Name: "binance-api-base-url", Usage: "Binance API base URL (required if binance feed is enabled)", - Value: "", + Value: "https://api.binance.com", EnvVar: prefixEnvVar("BINANCE_API_BASE_URL"), } + OKXAPIBaseURLFlag = cli.StringFlag{ + Name: "okx-api-base-url", + Usage: "OKX API base URL (required if okx feed is enabled)", + Value: "https://www.okx.com", + EnvVar: prefixEnvVar("OKX_API_BASE_URL"), + } + + ChainlinkRPCFlag = cli.StringFlag{ + Name: "chainlink-rpc", + Usage: "RPC endpoint used to read Chainlink AggregatorV3 feeds", + Value: "", + EnvVar: prefixEnvVar("CHAINLINK_RPC"), + } + + ChainlinkETHUSDFeedFlag = cli.StringFlag{ + Name: "chainlink-eth-usd-feed", + Usage: "Chainlink AggregatorV3 ETH/USD feed address", + Value: "", + EnvVar: prefixEnvVar("CHAINLINK_ETH_USD_FEED"), + } + + ChainlinkMaxStalenessFlag = cli.DurationFlag{ + Name: "chainlink-max-staleness", + Usage: "Maximum allowed age for Chainlink feed rounds", + Value: 1 * time.Hour, + EnvVar: prefixEnvVar("CHAINLINK_MAX_STALENESS"), + } + + PythHermesBaseURLFlag = cli.StringFlag{ + Name: "pyth-hermes-base-url", + Usage: "Pyth Hermes API base URL (required if pyth feed is enabled)", + Value: "https://hermes.pyth.network", + EnvVar: prefixEnvVar("PYTH_HERMES_BASE_URL"), + } + + PythAPIKeyFlag = cli.StringFlag{ + Name: "pyth-api-key", + Usage: "Pyth Hermes API key for authenticated requests", + Value: "", + EnvVar: prefixEnvVar("PYTH_API_KEY"), + } + + PythETHUSDPriceIDFlag = cli.StringFlag{ + Name: "pyth-eth-usd-price-id", + Usage: "Pyth ETH/USD price ID", + Value: "", + EnvVar: prefixEnvVar("PYTH_ETH_USD_PRICE_ID"), + } + + PythMaxStalenessFlag = cli.DurationFlag{ + Name: "pyth-max-staleness", + Usage: "Maximum allowed age for Pyth price publish time", + Value: 1 * time.Hour, + EnvVar: prefixEnvVar("PYTH_MAX_STALENESS"), + } + + PythMaxConfidenceBPSFlag = cli.Uint64Flag{ + Name: "pyth-max-confidence-bps", + Usage: "Maximum allowed Pyth confidence interval in basis points relative to price (0 disables confidence check)", + Value: 0, + EnvVar: prefixEnvVar("PYTH_MAX_CONFIDENCE_BPS"), + } + // Logging flags LogLevelFlag = cli.StringFlag{ Name: "log-level", @@ -203,8 +287,20 @@ var optionalFlags = []cli.Flag{ PriceFeedPriorityFlag, TokenMappingBitgetFlag, TokenMappingBinanceFlag, + TokenMappingOKXFlag, + TokenMappingChainlinkFlag, + TokenMappingPythFlag, BitgetAPIBaseURLFlag, BinanceAPIBaseURLFlag, + OKXAPIBaseURLFlag, + ChainlinkRPCFlag, + ChainlinkETHUSDFeedFlag, + ChainlinkMaxStalenessFlag, + PythHermesBaseURLFlag, + PythAPIKeyFlag, + PythETHUSDPriceIDFlag, + PythMaxStalenessFlag, + PythMaxConfidenceBPSFlag, LogLevelFlag, LogFilenameFlag, diff --git a/token-price-oracle/local.sh b/token-price-oracle/local.sh index 609390ce0..4abf74180 100644 --- a/token-price-oracle/local.sh +++ b/token-price-oracle/local.sh @@ -21,3 +21,18 @@ # - Stablecoins: tokenID:$PRICE (e.g., 3:$1.0 for USDT pegged to $1 USD) # Note: Use \$ in bash to escape the dollar sign +# Chainlink example: +# --price-feed-priority chainlink,pyth,bitget,binance,okx \ +# --chainlink-rpc https://ethereum-rpc.publicnode.com \ +# --chainlink-eth-usd-feed 0x... \ +# --chainlink-max-staleness 1h \ +# --token-mapping-chainlink "1:0x...,2:0x..." \ +# --pyth-hermes-base-url https://hermes.pyth.network \ +# --pyth-api-key "$PYTH_API_KEY" \ +# --pyth-eth-usd-price-id 0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace \ +# --pyth-max-staleness 1h \ +# --pyth-max-confidence-bps 0 \ +# --token-mapping-pyth "1:0x...,2:0x..." \ +# --token-mapping-binance "1:BTCUSDT,2:ETHUSDT" \ +# --token-mapping-okx "1:BTC-USDT,2:ETH-USDT" \ + diff --git a/token-price-oracle/updater/factory.go b/token-price-oracle/updater/factory.go index 18a54c205..a4b35a232 100644 --- a/token-price-oracle/updater/factory.go +++ b/token-price-oracle/updater/factory.go @@ -106,6 +106,41 @@ func createFallbackPriceFeed(cfg *config.Config) (client.PriceFeed, error) { // createSinglePriceFeed creates a single price feed instance func createSinglePriceFeed(feedType config.PriceFeedType, cfg *config.Config) (client.PriceFeed, string, error) { switch feedType { + case config.PriceFeedTypeChainlink: + mapping, exists := cfg.TokenMappings[config.PriceFeedTypeChainlink] + if !exists || len(mapping) == 0 { + return nil, "", fmt.Errorf("chainlink price feed requires token mapping, please configure --token-mapping-chainlink") + } + feed, err := client.NewChainlinkPriceFeed(mapping, cfg.ChainlinkRPC, cfg.ChainlinkETHUSDFeed, cfg.ChainlinkMaxStaleness) + if err != nil { + return nil, "", err + } + log.Info("Chainlink price feed created", + "type", "chainlink", + "rpc", cfg.ChainlinkRPC, + "eth_usd_feed", cfg.ChainlinkETHUSDFeed.Hex(), + "max_staleness", cfg.ChainlinkMaxStaleness, + "mapping", mapping) + return feed, "chainlink", nil + + case config.PriceFeedTypePyth: + mapping, exists := cfg.TokenMappings[config.PriceFeedTypePyth] + if !exists || len(mapping) == 0 { + return nil, "", fmt.Errorf("pyth price feed requires token mapping, please configure --token-mapping-pyth") + } + feed, err := client.NewPythHermesPriceFeed(mapping, cfg.PythHermesBaseURL, cfg.PythAPIKey, cfg.PythETHUSDPriceID, cfg.PythMaxStaleness, cfg.PythMaxConfidenceBPS) + if err != nil { + return nil, "", err + } + log.Info("Pyth price feed created", + "type", "pyth", + "base_url", cfg.PythHermesBaseURL, + "eth_usd_price_id", cfg.PythETHUSDPriceID, + "max_staleness", cfg.PythMaxStaleness, + "max_confidence_bps", cfg.PythMaxConfidenceBPS, + "mapping", mapping) + return feed, "pyth", nil + case config.PriceFeedTypeBitget: mapping, exists := cfg.TokenMappings[config.PriceFeedTypeBitget] if !exists || len(mapping) == 0 { @@ -119,9 +154,28 @@ func createSinglePriceFeed(feedType config.PriceFeedType, cfg *config.Config) (c return feed, "bitget", nil case config.PriceFeedTypeBinance: - // Binance price feed is not yet implemented - // This case should not be reached since Binance is not in ValidPriceFeedTypes - return nil, "", fmt.Errorf("binance price feed is not supported yet") + mapping, exists := cfg.TokenMappings[config.PriceFeedTypeBinance] + if !exists || len(mapping) == 0 { + return nil, "", fmt.Errorf("binance price feed requires token mapping, please configure --token-mapping-binance") + } + feed := client.NewBinancePriceFeed(mapping, cfg.BinanceAPIBaseURL) + log.Info("Binance price feed created", + "type", "binance", + "base_url", cfg.BinanceAPIBaseURL, + "mapping", mapping) + return feed, "binance", nil + + case config.PriceFeedTypeOKX: + mapping, exists := cfg.TokenMappings[config.PriceFeedTypeOKX] + if !exists || len(mapping) == 0 { + return nil, "", fmt.Errorf("okx price feed requires token mapping, please configure --token-mapping-okx") + } + feed := client.NewOKXPriceFeed(mapping, cfg.OKXAPIBaseURL) + log.Info("OKX price feed created", + "type", "okx", + "base_url", cfg.OKXAPIBaseURL, + "mapping", mapping) + return feed, "okx", nil default: return nil, "", fmt.Errorf("unsupported price feed type: %s", feedType)