mirror of
https://github.com/luxfi/price.git
synced 2026-10-30 15:21:24 +00:00
Lux Partners Limited is not the entity behind this work. Every place the old name stood — copyright lines, site footers, brand config, legal and compliance text — now names the company that actually holds it. lux.fund is the exception and is not renamed to match: that site belongs to Lux Assets Management LLC, a different company, and it says so. Two kinds of line were left for a person rather than rewritten. A dated news item recording that Lux Partners Limited was established in the Isle of Man is a historical fact, and renaming it would falsify the record. And a sentence asserting that the company is domiciled in the Isle of Man is a claim about a jurisdiction, not a name — an Inc or an LLC is not an Isle of Man company, so swapping the name there would assert something untrue. Co-authored-by: Hanzo Dev <[email protected]>
547 lines
16 KiB
Go
547 lines
16 KiB
Go
// Copyright (c) 2025 Lux Industries Inc
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
// CoinGecko API URLs
|
|
coingeckoProURL = "https://pro-api.coingecko.com/api/v3"
|
|
coingeckoDemoURL = "https://api.coingecko.com/api/v3"
|
|
|
|
// defaultCacheTTL is how long a quote is served before it is refetched.
|
|
//
|
|
// It was ONE HOUR, which is a display-ticker number and a dangerous one the
|
|
// moment a price decides an amount of money. An hour of BTC is routinely a
|
|
// few percent; served to a payer as "send this much", that difference is pure
|
|
// slippage and it is always the customer or us who eats it. A minute keeps a
|
|
// quote inside the window a person actually takes to copy an address and send.
|
|
//
|
|
// ⚠ THIS CACHE MUST NOT PRICE A SETTLEMENT. It answers "what is BTC worth
|
|
// roughly, now" for display and for sizing a quote. It does not answer "what
|
|
// did this deposit realise", and the difference is not staleness — it is that
|
|
// the second question has a correct answer (the executed fill) and this cache
|
|
// is not it. Credit what was actually received, never what was last quoted.
|
|
//
|
|
// Override with PRICE_CACHE_TTL (any Go duration). Shorter costs upstream
|
|
// quota: the demo CoinGecko tier is rate-limited, so a very short TTL across
|
|
// many tokens will start failing upstream and serving stale-on-error instead
|
|
// — which is the opposite of fresh. Pro tier is what makes seconds sane.
|
|
defaultCacheTTL = 1 * time.Minute
|
|
|
|
// Default port
|
|
defaultPort = "8080"
|
|
)
|
|
|
|
// cacheTTL is resolved once at startup from PRICE_CACHE_TTL, falling back to
|
|
// defaultCacheTTL. A malformed or non-positive value is REFUSED rather than
|
|
// silently defaulted: "0s" would mean no caching at all and would exhaust the
|
|
// upstream quota within minutes, and a typo that reads as "cache forever" is the
|
|
// failure this whole comment exists to prevent.
|
|
func resolveCacheTTL() (time.Duration, error) {
|
|
raw := strings.TrimSpace(os.Getenv("PRICE_CACHE_TTL"))
|
|
if raw == "" {
|
|
return defaultCacheTTL, nil
|
|
}
|
|
d, err := time.ParseDuration(raw)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("PRICE_CACHE_TTL %q is not a duration: %w", raw, err)
|
|
}
|
|
if d <= 0 {
|
|
return 0, fmt.Errorf("PRICE_CACHE_TTL %q must be positive; every request would hit upstream", raw)
|
|
}
|
|
return d, nil
|
|
}
|
|
|
|
// PriceCache holds cached price data
|
|
type PriceCache struct {
|
|
mu sync.RWMutex
|
|
prices map[string]*CachedPrice
|
|
apiKey string
|
|
pro bool
|
|
ttl time.Duration
|
|
baseURL string
|
|
client *http.Client
|
|
}
|
|
|
|
// CachedPrice holds a single cached price entry
|
|
type CachedPrice struct {
|
|
Price float64 `json:"price"`
|
|
Currency string `json:"currency"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
Change24h float64 `json:"change_24h,omitempty"`
|
|
MarketCap float64 `json:"market_cap,omitempty"`
|
|
Volume24h float64 `json:"volume_24h,omitempty"`
|
|
}
|
|
|
|
// PriceResponse is the API response format
|
|
type PriceResponse struct {
|
|
ID string `json:"id"`
|
|
Symbol string `json:"symbol"`
|
|
Name string `json:"name"`
|
|
Price float64 `json:"price"`
|
|
Currency string `json:"currency"`
|
|
Change24h float64 `json:"change_24h"`
|
|
MarketCap float64 `json:"market_cap"`
|
|
Volume24h float64 `json:"volume_24h"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
Cached bool `json:"cached"`
|
|
}
|
|
|
|
// MultiPriceResponse for multiple tokens
|
|
type MultiPriceResponse struct {
|
|
Prices map[string]*PriceResponse `json:"prices"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// CoinGecko API response structures
|
|
type CoinGeckoPrice struct {
|
|
ID string `json:"id"`
|
|
Symbol string `json:"symbol"`
|
|
Name string `json:"name"`
|
|
CurrentPrice float64 `json:"current_price"`
|
|
MarketCap float64 `json:"market_cap"`
|
|
TotalVolume float64 `json:"total_volume"`
|
|
PriceChangePercentage24h float64 `json:"price_change_percentage_24h"`
|
|
LastUpdated string `json:"last_updated"`
|
|
}
|
|
|
|
// NewPriceCache creates a new price cache
|
|
func NewPriceCache(apiKey string, ttl time.Duration) *PriceCache {
|
|
// The tier is DECLARED, never guessed. This used to try to infer it from the
|
|
// key's shape, and could not: a Pro key and a Demo key are both "CG-" plus
|
|
// alphanumeric, so the branch assigned the demo host on BOTH sides and
|
|
// coingeckoProURL was referenced nowhere in the program. A Pro key therefore
|
|
// went to the demo host under the demo header — silently rate-limited at the
|
|
// free tier while we paid for Pro, with nothing in the logs to say so.
|
|
//
|
|
// Host and header must move together: Pro is pro-api.coingecko.com with
|
|
// x-cg-pro-api-key, Demo is api.coingecko.com with x-cg-demo-api-key. Sending
|
|
// one key to the other pair is rejected, so a mismatch is loud rather than
|
|
// quietly degraded — which is why this is one decision and not two settings.
|
|
//
|
|
// Demo is the default because it is the safe wrong answer: a Pro key on the
|
|
// demo tier is merely throttled, whereas a Demo key against Pro is refused.
|
|
pro := strings.EqualFold(strings.TrimSpace(os.Getenv("COINGECKO_TIER")), "pro")
|
|
baseURL := coingeckoDemoURL
|
|
if pro {
|
|
baseURL = coingeckoProURL
|
|
}
|
|
|
|
return &PriceCache{
|
|
prices: make(map[string]*CachedPrice),
|
|
apiKey: apiKey,
|
|
pro: pro,
|
|
ttl: ttl,
|
|
baseURL: baseURL,
|
|
client: &http.Client{Timeout: 30 * time.Second},
|
|
}
|
|
}
|
|
|
|
// apiKeyHeader is the header name this tier authenticates with. It is derived
|
|
// from the SAME flag that chose the host, so the pair can never drift apart.
|
|
func (pc *PriceCache) apiKeyHeader() string {
|
|
if pc.pro {
|
|
return "x-cg-pro-api-key"
|
|
}
|
|
return "x-cg-demo-api-key"
|
|
}
|
|
|
|
// GetPrice returns the price for a token, fetching if cache expired
|
|
func (pc *PriceCache) GetPrice(ctx context.Context, tokenID, currency string) (*PriceResponse, error) {
|
|
cacheKey := fmt.Sprintf("%s:%s", tokenID, currency)
|
|
|
|
// Check cache first
|
|
pc.mu.RLock()
|
|
cached, exists := pc.prices[cacheKey]
|
|
pc.mu.RUnlock()
|
|
|
|
if exists && time.Since(cached.UpdatedAt) < pc.ttl {
|
|
return &PriceResponse{
|
|
ID: tokenID,
|
|
Price: cached.Price,
|
|
Currency: cached.Currency,
|
|
Change24h: cached.Change24h,
|
|
MarketCap: cached.MarketCap,
|
|
Volume24h: cached.Volume24h,
|
|
UpdatedAt: cached.UpdatedAt,
|
|
Cached: true,
|
|
}, nil
|
|
}
|
|
|
|
// Fetch from CoinGecko
|
|
price, err := pc.fetchFromCoinGecko(ctx, tokenID, currency)
|
|
if err != nil {
|
|
// Return stale cache if available
|
|
if exists {
|
|
return &PriceResponse{
|
|
ID: tokenID,
|
|
Price: cached.Price,
|
|
Currency: cached.Currency,
|
|
Change24h: cached.Change24h,
|
|
MarketCap: cached.MarketCap,
|
|
Volume24h: cached.Volume24h,
|
|
UpdatedAt: cached.UpdatedAt,
|
|
Cached: true,
|
|
}, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
// Update cache
|
|
pc.mu.Lock()
|
|
pc.prices[cacheKey] = &CachedPrice{
|
|
Price: price.CurrentPrice,
|
|
Currency: currency,
|
|
UpdatedAt: time.Now(),
|
|
Change24h: price.PriceChangePercentage24h,
|
|
MarketCap: price.MarketCap,
|
|
Volume24h: price.TotalVolume,
|
|
}
|
|
pc.mu.Unlock()
|
|
|
|
return &PriceResponse{
|
|
ID: tokenID,
|
|
Symbol: price.Symbol,
|
|
Name: price.Name,
|
|
Price: price.CurrentPrice,
|
|
Currency: currency,
|
|
Change24h: price.PriceChangePercentage24h,
|
|
MarketCap: price.MarketCap,
|
|
Volume24h: price.TotalVolume,
|
|
UpdatedAt: time.Now(),
|
|
Cached: false,
|
|
}, nil
|
|
}
|
|
|
|
// GetMultiplePrices fetches prices for multiple tokens
|
|
func (pc *PriceCache) GetMultiplePrices(ctx context.Context, tokenIDs []string, currency string) (*MultiPriceResponse, error) {
|
|
response := &MultiPriceResponse{
|
|
Prices: make(map[string]*PriceResponse),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
|
|
// Check which tokens need fetching
|
|
var toFetch []string
|
|
for _, id := range tokenIDs {
|
|
cacheKey := fmt.Sprintf("%s:%s", id, currency)
|
|
|
|
pc.mu.RLock()
|
|
cached, exists := pc.prices[cacheKey]
|
|
pc.mu.RUnlock()
|
|
|
|
if exists && time.Since(cached.UpdatedAt) < pc.ttl {
|
|
response.Prices[id] = &PriceResponse{
|
|
ID: id,
|
|
Price: cached.Price,
|
|
Currency: cached.Currency,
|
|
Change24h: cached.Change24h,
|
|
MarketCap: cached.MarketCap,
|
|
Volume24h: cached.Volume24h,
|
|
UpdatedAt: cached.UpdatedAt,
|
|
Cached: true,
|
|
}
|
|
} else {
|
|
toFetch = append(toFetch, id)
|
|
}
|
|
}
|
|
|
|
// Fetch missing prices in batch
|
|
if len(toFetch) > 0 {
|
|
prices, err := pc.fetchMultipleFromCoinGecko(ctx, toFetch, currency)
|
|
if err != nil {
|
|
log.Printf("Error fetching prices: %v", err)
|
|
} else {
|
|
for _, p := range prices {
|
|
cacheKey := fmt.Sprintf("%s:%s", p.ID, currency)
|
|
|
|
pc.mu.Lock()
|
|
pc.prices[cacheKey] = &CachedPrice{
|
|
Price: p.CurrentPrice,
|
|
Currency: currency,
|
|
UpdatedAt: time.Now(),
|
|
Change24h: p.PriceChangePercentage24h,
|
|
MarketCap: p.MarketCap,
|
|
Volume24h: p.TotalVolume,
|
|
}
|
|
pc.mu.Unlock()
|
|
|
|
response.Prices[p.ID] = &PriceResponse{
|
|
ID: p.ID,
|
|
Symbol: p.Symbol,
|
|
Name: p.Name,
|
|
Price: p.CurrentPrice,
|
|
Currency: currency,
|
|
Change24h: p.PriceChangePercentage24h,
|
|
MarketCap: p.MarketCap,
|
|
Volume24h: p.TotalVolume,
|
|
UpdatedAt: time.Now(),
|
|
Cached: false,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return response, nil
|
|
}
|
|
|
|
// fetchFromCoinGecko fetches a single price from CoinGecko
|
|
func (pc *PriceCache) fetchFromCoinGecko(ctx context.Context, tokenID, currency string) (*CoinGeckoPrice, error) {
|
|
url := fmt.Sprintf("%s/coins/markets?vs_currency=%s&ids=%s&order=market_cap_desc&per_page=1&page=1&sparkline=false",
|
|
pc.baseURL, currency, tokenID)
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if pc.apiKey != "" {
|
|
req.Header.Set(pc.apiKeyHeader(), pc.apiKey)
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
resp, err := pc.client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("CoinGecko API error: %d - %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var prices []CoinGeckoPrice
|
|
if err := json.NewDecoder(resp.Body).Decode(&prices); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(prices) == 0 {
|
|
return nil, fmt.Errorf("token not found: %s", tokenID)
|
|
}
|
|
|
|
return &prices[0], nil
|
|
}
|
|
|
|
// fetchMultipleFromCoinGecko fetches multiple prices in one request
|
|
func (pc *PriceCache) fetchMultipleFromCoinGecko(ctx context.Context, tokenIDs []string, currency string) ([]CoinGeckoPrice, error) {
|
|
ids := strings.Join(tokenIDs, ",")
|
|
url := fmt.Sprintf("%s/coins/markets?vs_currency=%s&ids=%s&order=market_cap_desc&per_page=250&page=1&sparkline=false",
|
|
pc.baseURL, currency, ids)
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if pc.apiKey != "" {
|
|
req.Header.Set(pc.apiKeyHeader(), pc.apiKey)
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
resp, err := pc.client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("CoinGecko API error: %d - %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var prices []CoinGeckoPrice
|
|
if err := json.NewDecoder(resp.Body).Decode(&prices); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return prices, nil
|
|
}
|
|
|
|
// Server holds the HTTP server and price cache
|
|
type Server struct {
|
|
cache *PriceCache
|
|
}
|
|
|
|
// NewServer creates a new server. The TTL is passed in rather than read here so
|
|
// that a malformed PRICE_CACHE_TTL fails at STARTUP, where somebody sees it,
|
|
// instead of silently falling back to a default and serving quotes at a
|
|
// staleness nobody chose.
|
|
func NewServer(apiKey string, ttl time.Duration) *Server {
|
|
return &Server{
|
|
cache: NewPriceCache(apiKey, ttl),
|
|
}
|
|
}
|
|
|
|
// handleHealth returns health status
|
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]string{
|
|
"status": "ok",
|
|
"time": time.Now().UTC().Format(time.RFC3339),
|
|
})
|
|
}
|
|
|
|
// handlePrice returns price for a single token
|
|
func (s *Server) handlePrice(w http.ResponseWriter, r *http.Request) {
|
|
// Parse token ID from path: /price/{token_id}
|
|
path := strings.TrimPrefix(r.URL.Path, "/price/")
|
|
tokenID := strings.TrimSuffix(path, "/")
|
|
|
|
if tokenID == "" {
|
|
http.Error(w, `{"error":"token_id required"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Get currency from query param, default to usd
|
|
currency := r.URL.Query().Get("currency")
|
|
if currency == "" {
|
|
currency = "usd"
|
|
}
|
|
|
|
price, err := s.cache.GetPrice(r.Context(), tokenID, currency)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf(`{"error":"%s"}`, err.Error()), http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
|
json.NewEncoder(w).Encode(price)
|
|
}
|
|
|
|
// handlePrices returns prices for multiple tokens
|
|
func (s *Server) handlePrices(w http.ResponseWriter, r *http.Request) {
|
|
// Get token IDs from query param
|
|
ids := r.URL.Query().Get("ids")
|
|
if ids == "" {
|
|
http.Error(w, `{"error":"ids query parameter required"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
tokenIDs := strings.Split(ids, ",")
|
|
|
|
// Get currency from query param, default to usd
|
|
currency := r.URL.Query().Get("currency")
|
|
if currency == "" {
|
|
currency = "usd"
|
|
}
|
|
|
|
prices, err := s.cache.GetMultiplePrices(r.Context(), tokenIDs, currency)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf(`{"error":"%s"}`, err.Error()), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
|
json.NewEncoder(w).Encode(prices)
|
|
}
|
|
|
|
// handleSimplePrice returns simple price map (CoinGecko compatible)
|
|
func (s *Server) handleSimplePrice(w http.ResponseWriter, r *http.Request) {
|
|
ids := r.URL.Query().Get("ids")
|
|
if ids == "" {
|
|
http.Error(w, `{"error":"ids query parameter required"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
vsCurrencies := r.URL.Query().Get("vs_currencies")
|
|
if vsCurrencies == "" {
|
|
vsCurrencies = "usd"
|
|
}
|
|
|
|
tokenIDs := strings.Split(ids, ",")
|
|
currencies := strings.Split(vsCurrencies, ",")
|
|
|
|
result := make(map[string]map[string]float64)
|
|
|
|
for _, currency := range currencies {
|
|
prices, _ := s.cache.GetMultiplePrices(r.Context(), tokenIDs, currency)
|
|
for id, p := range prices.Prices {
|
|
if result[id] == nil {
|
|
result[id] = make(map[string]float64)
|
|
}
|
|
result[id][currency] = p.Price
|
|
}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
|
json.NewEncoder(w).Encode(result)
|
|
}
|
|
|
|
// corsMiddleware adds CORS headers
|
|
func corsMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
|
|
|
if r.Method == "OPTIONS" {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func main() {
|
|
// CoinGecko API key — optional. When unset we fall back to the public
|
|
// demo CoinGecko endpoint with rate limits, suitable for dev/staging.
|
|
apiKey := os.Getenv("COINGECKO_API_KEY")
|
|
if apiKey == "" {
|
|
log.Println("COINGECKO_API_KEY unset — using demo CoinGecko endpoint with public rate limits")
|
|
}
|
|
|
|
// Get port from environment or use default
|
|
port := os.Getenv("PORT")
|
|
if port == "" {
|
|
port = defaultPort
|
|
}
|
|
|
|
// Refuse to start on a bad TTL rather than quietly defaulting: the value
|
|
// decides how stale a quote may be, and a quote sizes an amount of money.
|
|
ttl, err := resolveCacheTTL()
|
|
if err != nil {
|
|
log.Fatalf("price: %v", err)
|
|
}
|
|
|
|
server := NewServer(apiKey, ttl)
|
|
|
|
// Set up routes
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/health", server.handleHealth)
|
|
mux.HandleFunc("/price/", server.handlePrice)
|
|
mux.HandleFunc("/prices", server.handlePrices)
|
|
mux.HandleFunc("/simple/price", server.handleSimplePrice)
|
|
|
|
// Add CORS middleware
|
|
handler := corsMiddleware(mux)
|
|
|
|
log.Printf("Starting pricing API server on port %s", port)
|
|
log.Printf("Cache TTL: %v", ttl)
|
|
log.Printf("Endpoints:")
|
|
log.Printf(" GET /health - Health check")
|
|
log.Printf(" GET /price/{token_id}?currency=usd - Get single token price")
|
|
log.Printf(" GET /prices?ids=bitcoin,ethereum¤cy=usd - Get multiple prices")
|
|
log.Printf(" GET /simple/price?ids=bitcoin&vs_currencies=usd - CoinGecko compatible")
|
|
|
|
if err := http.ListenAndServe(":"+port, handler); err != nil {
|
|
log.Fatalf("Server failed: %v", err)
|
|
}
|
|
}
|