Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
2e4be7d
feat(token-price-oracle): add Chainlink price feed
Jun 22, 2026
ef6d751
feat(token-price-oracle): add Pyth and CEX price feeds
Jun 22, 2026
04b5ea9
fix(token-price-oracle): request parsed Pyth prices
Jun 22, 2026
a5a77e7
Merge branch 'main' into feat/977-chainlink-token-price-oracle
curryxbo Jul 22, 2026
1596872
feat(genesis): pre-register test tokens in TokenRegistry for devnet
curryxbo Jul 25, 2026
d1b686a
docs: add TokenRegistry pre-registration documentation
curryxbo Jul 25, 2026
b1be127
docs: add today's work summary
curryxbo Jul 25, 2026
4174e3d
fix(token-price-oracle): address review findings
Jul 27, 2026
1dec6bd
Merge branch 'main' into feat/977-chainlink-token-price-oracle
curryxbo Jul 27, 2026
83a8214
Merge branch 'main' into feat/977-chainlink-token-price-oracle
curryxbo Jul 31, 2026
939d007
fix(node): drop the layer1-verify override of derivation confirmations
Jul 31, 2026
4b40bb2
fix(token-price-oracle): unify GetTokenPrice preconditions across CEX…
Aug 3, 2026
1bab5a9
fix(token-price-oracle): address review findings on feeds, genesis an…
Aug 3, 2026
7d18942
fix(token-price-oracle): resolve batch prices across feeds instead of…
Aug 3, 2026
e974620
fix(token-price-oracle): allow bounded clock skew on Pyth publish time
Aug 3, 2026
9c5e25b
fix(genesis): register devnet test tokens with a scale of 10^decimals
Aug 3, 2026
dbbf296
docs(devnet): note that devnet-down keeps the L1 data volume
Aug 3, 2026
ede8cca
docs(devnet): show how to produce the devnet.env the docker run expects
Aug 3, 2026
09c0a6c
fix(token-price-oracle): point local.sh at the devnet token IDs
Aug 3, 2026
5d60d5a
docs(token-price-oracle): spell out why the Pyth key is required befo…
Aug 3, 2026
276e87a
docs(token-price-oracle): move the devnet guide out of the repository…
Aug 4, 2026
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"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This quick-start configuration cannot start as written. The priority enables Binance and OKX, but the example provides neither provider's required token mapping. LoadConfig rejects the configuration before startup. Limit the priority list to the feeds configured in this snippet, or include complete Binance and OKX mappings/base configuration.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1bab5a9. The quick start now includes the Binance and OKX mappings, which brings it in line with the example further down the same README that already had them.

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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

### 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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: strconv.ParseFloat accepts NaN and infinities. NaN passes the current <= 0 test and big.NewFloat(NaN) panics; positive infinity later causes conversion to an integer ratio to return nil and can panic the updater. The same issue exists in the fixed stablecoin parser. Reject math.IsNaN/math.IsInf and non-positive values before constructing big.Float, then test that malformed exchange data falls through to the next provider instead of crashing the process.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1bab5a9. Both parsers now go through a shared newFinitePositiveFloat guard that rejects NaN, infinities and non-positive values before big.NewFloat.

Two things worth flagging beyond the original report. Bitget carries its own copies of both parsers rather than reusing the CEX ones, and its ticker parser had no positivity check at all, so it accepted zero and negatives as well; both now use the shared guard. Separately, ParseFloat("1e400") does return an out-of-range error, so overflow via decimal literal was already rejected.

The test asserts rejection at the parser level for NaN, ±Inf, zero and negatives. I did not add an end-to-end test that a malformed ticker falls through to the next provider; the parser returns an error and FallbackPriceFeed already advances on error, but say the word if you would like that covered explicitly.

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