Skip to content
Open
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
77 changes: 68 additions & 9 deletions token-price-oracle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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)

Expand All @@ -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 |
Expand Down Expand Up @@ -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
Expand Down
249 changes: 249 additions & 0 deletions token-price-oracle/client/cex_feed.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading