Files
zeekayandHanzo Dev 1db9b4d974 the corporate name is Lux Industries Inc
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]>
2026-08-28 11:05:38 -07:00

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&currency=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)
}
}