feat: lux rice from live coingecko

This commit is contained in:
venuswhispers
2026-05-28 04:52:58 +02:00
parent a3215f9210
commit c8e77bd762
3 changed files with 502 additions and 6 deletions
+270
View File
@@ -0,0 +1,270 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strings"
"sync"
"time"
)
// coingecko_price_feed.go: HTTP PriceFeed backed by CoinGecko's
// simple/price endpoint. Drop-in replacement for StaticPriceFeed.
//
// Behavior:
// - Symbol → CoinGecko ID translation via IDMap (e.g. "ETH" → "ethereum").
// Symbols not in IDMap return ErrPriceUnknown (so a FallbackFeed can
// route them to the static feed — useful for LUX/ZOO which CoinGecko
// doesn't list).
// - One batched HTTP call per TTL window covers every configured symbol.
// Within the window, Price() reads the cache without hitting the
// network. Default TTL: 30s, well inside CoinGecko's free-tier limits.
// - On HTTP error (network down, 429, 5xx), all calls return the
// underlying error. Wrap with FallbackFeed to keep the bridge live
// during CoinGecko outages.
// =============================================================================
// CoinGeckoFeed
// =============================================================================
// DefaultCoinGeckoBaseURL is the public simple/price endpoint root.
const DefaultCoinGeckoBaseURL = "https://api.coingecko.com/api/v3"
// DefaultCoinGeckoCacheTTL is the freshness window for cached prices.
const DefaultCoinGeckoCacheTTL = 30 * time.Second
// DefaultCoinGeckoTimeout is the per-request HTTP timeout.
const DefaultCoinGeckoTimeout = 5 * time.Second
// DefaultCoinGeckoIDMap is the symbol → CoinGecko ID map seeded for the
// assets the bridge supports today. LUX and ZOO are deliberately absent;
// CoinGecko does not list them, so they must come from a fallback feed.
var DefaultCoinGeckoIDMap = map[string]string{
"ETH": "ethereum",
"BTC": "bitcoin",
"SOL": "solana",
"TON": "the-open-network",
"USDC": "usd-coin",
"USDT": "tether",
"DAI": "dai",
"BNB": "binancecoin",
}
// CoinGeckoFeed pulls USD spot prices from CoinGecko's simple/price
// endpoint. Concurrency-safe.
type CoinGeckoFeed struct {
// BaseURL is the API root, default DefaultCoinGeckoBaseURL.
// Tests override this with httptest.NewServer.URL.
BaseURL string
// APIKey is the CoinGecko Pro key (x-cg-pro-api-key header). Empty
// for the free tier; rate limits apply.
APIKey string
// HTTPClient is the transport. Defaults to a client with
// DefaultCoinGeckoTimeout. Tests can swap in a custom client.
HTTPClient *http.Client
// CacheTTL is the freshness window. Zero ⇒ DefaultCoinGeckoCacheTTL.
CacheTTL time.Duration
// IDMap is the symbol → CoinGecko ID lookup. Zero-value ⇒
// DefaultCoinGeckoIDMap. Symbol matching is case-insensitive.
IDMap map[string]string
mu sync.Mutex
cache map[string]float64 // upper-case symbol → USD
cachedAt time.Time
inFlight chan struct{} // single-flight gate around HTTP fetch
}
// NewCoinGeckoFeed builds a feed with sensible defaults. Pass nil for
// idMap to use DefaultCoinGeckoIDMap.
func NewCoinGeckoFeed(baseURL, apiKey string, idMap map[string]string) *CoinGeckoFeed {
if baseURL == "" {
baseURL = DefaultCoinGeckoBaseURL
}
if idMap == nil {
idMap = DefaultCoinGeckoIDMap
}
upper := make(map[string]string, len(idMap))
for k, v := range idMap {
upper[strings.ToUpper(k)] = v
}
return &CoinGeckoFeed{
BaseURL: baseURL,
APIKey: apiKey,
HTTPClient: &http.Client{Timeout: DefaultCoinGeckoTimeout},
IDMap: upper,
}
}
// Price returns the USD value of one unit of asset.
func (f *CoinGeckoFeed) Price(ctx context.Context, asset string) (float64, error) {
sym := strings.ToUpper(asset)
if _, ok := f.IDMap[sym]; !ok {
return 0, fmt.Errorf("%w: %s (not in CoinGecko ID map)", ErrPriceUnknown, asset)
}
if v, ok := f.cachedFresh(sym); ok {
return v, nil
}
if err := f.refresh(ctx); err != nil {
return 0, err
}
if v, ok := f.cachedFresh(sym); ok {
return v, nil
}
return 0, fmt.Errorf("%w: %s (CoinGecko response missing this id)", ErrPriceUnknown, asset)
}
func (f *CoinGeckoFeed) cachedFresh(sym string) (float64, bool) {
f.mu.Lock()
defer f.mu.Unlock()
if f.cache == nil || time.Since(f.cachedAt) > f.ttl() {
return 0, false
}
v, ok := f.cache[sym]
return v, ok
}
func (f *CoinGeckoFeed) ttl() time.Duration {
if f.CacheTTL > 0 {
return f.CacheTTL
}
return DefaultCoinGeckoCacheTTL
}
// refresh fetches every configured symbol's USD price in one HTTP call
// and replaces the cache. Single-flight: concurrent refreshes coalesce
// onto whichever goroutine got there first.
func (f *CoinGeckoFeed) refresh(ctx context.Context) error {
f.mu.Lock()
if f.inFlight != nil {
ch := f.inFlight
f.mu.Unlock()
select {
case <-ch:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
ch := make(chan struct{})
f.inFlight = ch
f.mu.Unlock()
defer func() {
f.mu.Lock()
f.inFlight = nil
close(ch)
f.mu.Unlock()
}()
ids := make([]string, 0, len(f.IDMap))
for _, v := range f.IDMap {
ids = append(ids, v)
}
sort.Strings(ids) // deterministic URL for caching layers + test reproducibility
q := url.Values{}
q.Set("ids", strings.Join(ids, ","))
q.Set("vs_currencies", "usd")
endpoint := strings.TrimRight(f.BaseURL, "/") + "/simple/price?" + q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return fmt.Errorf("coingecko: build request: %w", err)
}
req.Header.Set("Accept", "application/json")
if f.APIKey != "" {
req.Header.Set("x-cg-pro-api-key", f.APIKey)
}
client := f.HTTPClient
if client == nil {
client = &http.Client{Timeout: DefaultCoinGeckoTimeout}
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("coingecko: http: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("coingecko: status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var payload map[string]map[string]float64
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return fmt.Errorf("coingecko: decode: %w", err)
}
next := make(map[string]float64, len(f.IDMap))
for sym, id := range f.IDMap {
if row, ok := payload[id]; ok {
if usd, ok := row["usd"]; ok && usd > 0 {
next[sym] = usd
}
}
}
f.mu.Lock()
f.cache = next
f.cachedAt = time.Now()
f.mu.Unlock()
return nil
}
// =============================================================================
// FallbackFeed
// =============================================================================
// FallbackFeed tries Primary first and falls back to Secondary on any
// error. Use it to wrap a CoinGeckoFeed (which doesn't know about LUX /
// ZOO) over a StaticPriceFeed seeded with the missing assets — and to
// keep quoting live during CoinGecko outages.
type FallbackFeed struct {
Primary PriceFeed
Secondary PriceFeed
// OnFallback is called when Primary errors and Secondary is consulted.
// Optional; used for ops logging. The error from Primary is passed in.
OnFallback func(asset string, err error)
}
// Price returns Primary's price, or Secondary's if Primary errored.
func (f *FallbackFeed) Price(ctx context.Context, asset string) (float64, error) {
if f.Primary != nil {
v, err := f.Primary.Price(ctx, asset)
if err == nil {
return v, nil
}
if f.OnFallback != nil {
f.OnFallback(asset, err)
}
if f.Secondary == nil {
return 0, err
}
v2, err2 := f.Secondary.Price(ctx, asset)
if err2 == nil {
return v2, nil
}
// Both failed. If Primary said ErrPriceUnknown, the Secondary
// error is what callers want (it explains why the fallback
// also doesn't know). Otherwise surface the Primary error so
// network/auth failures aren't masked by "unknown asset".
if errors.Is(err, ErrPriceUnknown) {
return 0, err2
}
return 0, err
}
if f.Secondary == nil {
return 0, errors.New("fallback_feed: no feeds configured")
}
return f.Secondary.Price(ctx, asset)
}
+179
View File
@@ -0,0 +1,179 @@
package main
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
// fakeCG builds an httptest.Server that answers /simple/price with
// the supplied id→usd table and counts requests.
func fakeCG(t *testing.T, prices map[string]float64) (*httptest.Server, *int32) {
t.Helper()
var calls int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&calls, 1)
if !strings.HasSuffix(r.URL.Path, "/simple/price") {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
var b strings.Builder
b.WriteString("{")
first := true
for id, usd := range prices {
if !first {
b.WriteString(",")
}
first = false
fmt.Fprintf(&b, "%q:{\"usd\":%v}", id, usd)
}
b.WriteString("}")
_, _ = w.Write([]byte(b.String()))
}))
t.Cleanup(srv.Close)
return srv, &calls
}
func TestCoinGeckoFeed_ReturnsMappedPrice(t *testing.T) {
srv, calls := fakeCG(t, map[string]float64{
"ethereum": 4200.0,
"bitcoin": 70000.0,
})
f := NewCoinGeckoFeed(srv.URL, "", map[string]string{
"ETH": "ethereum",
"BTC": "bitcoin",
})
v, err := f.Price(context.Background(), "ETH")
if err != nil {
t.Fatalf("ETH: %v", err)
}
if v != 4200.0 {
t.Errorf("ETH = %v, want 4200", v)
}
v, err = f.Price(context.Background(), "btc") // case-insensitive
if err != nil {
t.Fatalf("BTC: %v", err)
}
if v != 70000.0 {
t.Errorf("BTC = %v, want 70000", v)
}
if got := atomic.LoadInt32(calls); got != 1 {
t.Errorf("expected 1 batched HTTP call, got %d", got)
}
}
func TestCoinGeckoFeed_UnknownSymbol_ReturnsErrPriceUnknown(t *testing.T) {
srv, _ := fakeCG(t, map[string]float64{"ethereum": 4200})
f := NewCoinGeckoFeed(srv.URL, "", map[string]string{"ETH": "ethereum"})
_, err := f.Price(context.Background(), "LUX")
if !errors.Is(err, ErrPriceUnknown) {
t.Fatalf("LUX expected ErrPriceUnknown, got %v", err)
}
}
func TestCoinGeckoFeed_CachesWithinTTL(t *testing.T) {
srv, calls := fakeCG(t, map[string]float64{"ethereum": 4200})
f := NewCoinGeckoFeed(srv.URL, "", map[string]string{"ETH": "ethereum"})
f.CacheTTL = 5 * time.Second
for i := 0; i < 5; i++ {
if _, err := f.Price(context.Background(), "ETH"); err != nil {
t.Fatal(err)
}
}
if got := atomic.LoadInt32(calls); got != 1 {
t.Errorf("expected single fetch within TTL, got %d", got)
}
}
func TestCoinGeckoFeed_HTTPError_Propagates(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "rate limited", http.StatusTooManyRequests)
}))
t.Cleanup(srv.Close)
f := NewCoinGeckoFeed(srv.URL, "", map[string]string{"ETH": "ethereum"})
_, err := f.Price(context.Background(), "ETH")
if err == nil {
t.Fatal("expected error on 429, got nil")
}
if !strings.Contains(err.Error(), "429") {
t.Errorf("expected error to mention 429, got %v", err)
}
}
func TestCoinGeckoFeed_SendsAPIKeyHeader(t *testing.T) {
var seenKey string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seenKey = r.Header.Get("x-cg-pro-api-key")
_, _ = w.Write([]byte(`{"ethereum":{"usd":1}}`))
}))
t.Cleanup(srv.Close)
f := NewCoinGeckoFeed(srv.URL, "secret-key", map[string]string{"ETH": "ethereum"})
if _, err := f.Price(context.Background(), "ETH"); err != nil {
t.Fatal(err)
}
if seenKey != "secret-key" {
t.Errorf("api key header = %q, want %q", seenKey, "secret-key")
}
}
func TestFallbackFeed_PrimarySucceeds(t *testing.T) {
p := NewStaticPriceFeed(map[string]float64{"ETH": 4000})
s := NewStaticPriceFeed(map[string]float64{"ETH": 100})
f := &FallbackFeed{Primary: p, Secondary: s}
v, err := f.Price(context.Background(), "ETH")
if err != nil || v != 4000 {
t.Errorf("ETH = %v err=%v, want 4000 nil", v, err)
}
}
func TestFallbackFeed_FallsBackOnUnknown(t *testing.T) {
p := NewStaticPriceFeed(nil) // knows nothing
s := NewStaticPriceFeed(map[string]float64{"LUX": 2.5})
called := false
f := &FallbackFeed{
Primary: p, Secondary: s,
OnFallback: func(asset string, err error) { called = true },
}
v, err := f.Price(context.Background(), "LUX")
if err != nil || v != 2.5 {
t.Errorf("LUX = %v err=%v, want 2.5 nil", v, err)
}
if !called {
t.Error("OnFallback hook not called")
}
}
func TestFallbackFeed_BothFail_SurfacesNetworkError(t *testing.T) {
// Primary errors with a non-Unknown error (simulating rate-limit /
// network failure). Secondary doesn't know the asset either. The
// FallbackFeed should surface the network error, not the asset-
// unknown one — operators need to see CoinGecko is broken.
primary := errFeed{err: errors.New("network down")}
secondary := NewStaticPriceFeed(nil)
f := &FallbackFeed{Primary: primary, Secondary: secondary}
_, err := f.Price(context.Background(), "ETH")
if err == nil || !strings.Contains(err.Error(), "network down") {
t.Errorf("expected network-down surfaced, got %v", err)
}
}
type errFeed struct{ err error }
func (e errFeed) Price(_ context.Context, _ string) (float64, error) { return 0, e.err }
+53 -6
View File
@@ -28,8 +28,10 @@ package main
import (
"context"
"errors"
"flag"
"fmt"
"net/http"
"os"
"os/signal"
"path/filepath"
@@ -134,6 +136,16 @@ func main() {
"path to a JSON file that persists per-destination-network release wallets (the long-lived MPC addresses that pay out settlements). When set, the bridge mints a release wallet on first need and reuses it across restarts. When empty AND --data-dir is set, defaults to <data-dir>/release-wallets.json. When empty AND --data-dir is empty, runs in-memory — every restart re-mints and any liquidity at the old address is stranded. Required for prod.")
profileFlag := flag.String("profile", envOr("BRIDGE_PROFILE", "classical-compat"),
"bridge security profile: strict-pq | classical-compat")
coingeckoEnabled := flag.Bool("coingecko", envBool("BRIDGE_COINGECKO", false),
"enable CoinGecko HTTP price feed (wrapped over the static feed as a fallback for LUX/ZOO and outages). When disabled (default), the static feed is the sole source of prices.")
coingeckoURL := flag.String("coingecko-url", envOr("BRIDGE_COINGECKO_URL", DefaultCoinGeckoBaseURL),
"CoinGecko API base URL")
coingeckoAPIKey := flag.String("coingecko-api-key", envOr("BRIDGE_COINGECKO_API_KEY", ""),
"CoinGecko Pro API key (empty for the free tier)")
coingeckoTTL := flag.Duration("coingecko-cache-ttl", DefaultCoinGeckoCacheTTL,
"how long CoinGecko prices are cached before re-fetching. The fetch batches every configured symbol into one HTTP call.")
coingeckoTimeout := flag.Duration("coingecko-timeout", DefaultCoinGeckoTimeout,
"per-request HTTP timeout for CoinGecko calls")
showVersion := flag.Bool("version", false, "print version and exit")
flag.Parse()
@@ -300,11 +312,12 @@ func main() {
}
}
}()
// Seed the static price feed with the values used in dev / first
// deploys. Real production wiring should swap in a CoinGecko / Pyth
// PriceFeed implementation. These match the order-of-magnitude
// prices the TS app/server queries via getTokenPrice.
priceFeed := NewStaticPriceFeed(map[string]float64{
// Static feed seeds the assets CoinGecko doesn't list (LUX, ZOO) and
// serves as the fallback when CoinGecko is unreachable. Order-of-
// magnitude values matching the TS app/server's getTokenPrice — the
// CG-backed entries are overridden at runtime, the rest are the
// permanent source for LUX/ZOO.
staticFeed := NewStaticPriceFeed(map[string]float64{
"ETH": 3500.00,
"LUX": 2.50,
"ZOO": 0.05,
@@ -316,11 +329,31 @@ func main() {
"DAI": 1.00,
"BNB": 600.00,
})
var priceFeed PriceFeed = staticFeed
feedLabel := "static"
if *coingeckoEnabled {
cg := NewCoinGeckoFeed(*coingeckoURL, *coingeckoAPIKey, nil)
cg.CacheTTL = *coingeckoTTL
cg.HTTPClient = &http.Client{Timeout: *coingeckoTimeout}
priceFeed = &FallbackFeed{
Primary: cg,
Secondary: staticFeed,
OnFallback: func(asset string, err error) {
if errors.Is(err, ErrPriceUnknown) {
return // expected for LUX/ZOO — not worth a log line per quote
}
logger.Warn("coingecko price fallback", "asset", asset, "err", err)
},
}
feedLabel = "coingecko+static"
}
quoteEngine := &QuoteEngine{Feed: priceFeed}
logger.Info("native swap CRUD enabled",
"store", storeLabel,
"data_dir", *dataDir,
"feed", "static",
"feed", feedLabel,
"fee_rate", quoteEngine.FeeRate,
)
@@ -585,6 +618,20 @@ func envOr(k, fallback string) string {
return fallback
}
func envBool(k string, fallback bool) bool {
v := os.Getenv(k)
if v == "" {
return fallback
}
switch strings.ToLower(v) {
case "1", "t", "true", "yes", "y", "on":
return true
case "0", "f", "false", "no", "n", "off":
return false
}
return fallback
}
// selectProfile resolves the --profile flag value to a bridge profile
// pointer. Refuses an unknown value rather than silently defaulting:
// an operator who typos the profile name must see the error rather