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]>
160 lines
4.0 KiB
Go
160 lines
4.0 KiB
Go
// Copyright (c) 2025 Lux Industries Inc
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package source
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// coinbaseDefaultURL is the Coinbase public retail spot price endpoint.
|
|
// It is unauthenticated and rate-limited per IP.
|
|
const coinbaseDefaultURL = "https://api.coinbase.com/v2"
|
|
|
|
// Coinbase fetches per-pair spot prices from /v2/prices/{symbol}-USD/spot.
|
|
//
|
|
// Coinbase keys quotes by ticker symbol (BTC, ETH, ...) while our oracle
|
|
// uses CoinGecko-style canonical IDs (bitcoin, ethereum, ...). The Source
|
|
// holds an ID->symbol map that callers seed at construction time so the
|
|
// translation layer is explicit and testable.
|
|
type Coinbase struct {
|
|
baseURL string
|
|
client *http.Client
|
|
idToSym map[string]string // canonical ID -> coinbase ticker
|
|
enabled bool
|
|
}
|
|
|
|
// NewCoinbase constructs a Coinbase source.
|
|
//
|
|
// idToSymbol maps canonical token IDs ("bitcoin") to Coinbase tickers
|
|
// ("BTC"). IDs not present in the map are skipped during Fetch — the
|
|
// source will simply omit them from its result set, which the oracle
|
|
// treats as "no observation" rather than as an error.
|
|
//
|
|
// Pass a custom baseURL ("") to use the default Coinbase host.
|
|
func NewCoinbase(baseURL string, idToSymbol map[string]string) *Coinbase {
|
|
if baseURL == "" {
|
|
baseURL = coinbaseDefaultURL
|
|
}
|
|
if idToSymbol == nil {
|
|
idToSymbol = DefaultSymbolMap()
|
|
}
|
|
return &Coinbase{
|
|
baseURL: baseURL,
|
|
client: &http.Client{Timeout: 15 * time.Second},
|
|
idToSym: idToSymbol,
|
|
enabled: true,
|
|
}
|
|
}
|
|
|
|
// Name returns the canonical source identifier.
|
|
func (c *Coinbase) Name() string { return "coinbase" }
|
|
|
|
// Enabled reports whether this source is wired up for the fetcher.
|
|
func (c *Coinbase) Enabled() bool { return c != nil && c.enabled }
|
|
|
|
// Fetch returns quotes keyed by canonical token ID. Coinbase exposes one
|
|
// HTTP endpoint per pair, so we issue one request per known ID, in
|
|
// parallel, with a hard upper bound on concurrency.
|
|
func (c *Coinbase) Fetch(ctx context.Context, ids []string) (map[string]Quote, error) {
|
|
if !c.Enabled() || len(ids) == 0 {
|
|
return map[string]Quote{}, nil
|
|
}
|
|
|
|
type result struct {
|
|
id string
|
|
quote Quote
|
|
ok bool
|
|
}
|
|
|
|
resCh := make(chan result, len(ids))
|
|
sem := make(chan struct{}, 8) // 8 concurrent in-flight requests
|
|
var wg sync.WaitGroup
|
|
|
|
for _, id := range ids {
|
|
sym, ok := c.idToSym[strings.ToLower(id)]
|
|
if !ok {
|
|
continue // unknown to Coinbase — skip silently
|
|
}
|
|
wg.Add(1)
|
|
go func(id, sym string) {
|
|
defer wg.Done()
|
|
select {
|
|
case sem <- struct{}{}:
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
defer func() { <-sem }()
|
|
|
|
price, err := c.fetchSpot(ctx, sym)
|
|
if err != nil {
|
|
return
|
|
}
|
|
resCh <- result{
|
|
id: id,
|
|
quote: Quote{
|
|
ID: id,
|
|
Symbol: strings.ToLower(sym),
|
|
PriceUSD: price,
|
|
UpdatedAt: time.Now().UTC(),
|
|
SourceName: c.Name(),
|
|
},
|
|
ok: true,
|
|
}
|
|
}(id, sym)
|
|
}
|
|
|
|
go func() { wg.Wait(); close(resCh) }()
|
|
|
|
out := make(map[string]Quote)
|
|
for r := range resCh {
|
|
if r.ok {
|
|
out[r.id] = r.quote
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// fetchSpot returns the current spot price for SYMBOL-USD on Coinbase.
|
|
func (c *Coinbase) fetchSpot(ctx context.Context, symbol string) (float64, error) {
|
|
url := fmt.Sprintf("%s/prices/%s-USD/spot", c.baseURL, symbol)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
resp, err := c.client.Do(req)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return 0, fmt.Errorf("coinbase: status %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var payload struct {
|
|
Data struct {
|
|
Amount string `json:"amount"`
|
|
Currency string `json:"currency"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
|
return 0, err
|
|
}
|
|
if payload.Data.Amount == "" {
|
|
return 0, fmt.Errorf("coinbase: empty amount")
|
|
}
|
|
return strconv.ParseFloat(payload.Data.Amount, 64)
|
|
}
|