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

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