Files
zeekayandHanzo Dev 9864cb54a1 oracle: fill the empty slot — a justified price, or a refusal
pkg/oracle has been an empty directory while pkg/source sat unimported, so this
repo could FETCH a price and had nowhere to decide whether one may be believed.
The service answers from CoinGecko alone behind an hour cache, which is right for
a ticker and wrong for money: one upstream is one outage and one bad tick away
from valuing everything, and an hour-old BTC quote is routinely tens of dollars
stale (measured: $24.36 against the venues).

Every one of these REFUSES rather than falling back — fewer than two independent
sources answered, they disagree by more than 2%, or a source answered <= 0. One
error for all of them, because a caller's only correct response to any is to not
act and retry; distinguishing them invites handling one by acting anyway. New()
refuses a duplicate venue name at construction: two entries for one exchange
satisfy the COUNT while providing one opinion, which is the property MinSources
is meant to buy.

The median takes the LOWER middle, so with two sources it is the minimum. A venue
quoting high can never pull a price above what the conservative venue said.

Prices are MICRO-CENTS. XRP at $1.04295 and TON at $1.3412 become $1.04 and $1.34
as whole cents — 0.28% and 0.09% off on every use. Whole cents are precise enough
for BTC and nowhere near it for a sub-dollar coin, and precision cannot depend on
which asset is being priced. The float64 in source.Quote costs at most one
micro-cent per whole unit (~1e-10 dollars on a realistic deposit); the bound is
stated in microCents() rather than assumed, and carrying the venue's decimal
string through source.Quote would remove it.

Batch-shaped because source.Fetch is. An id that cannot be justified is OMITTED
rather than zero — a zero price is a number, and a number is something a caller
uses by accident.

Tests stub the venues so the JUDGEMENT is what is asserted, plus a live probe
(PRICE_LIVE=1) over bitcoin/ripple/the-open-network. The live one earned its keep
immediately: its BTC sanity floor was written as $100,000 instead of $1,000 and
failed against a correct price.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-09 09:29:47 -07:00

250 lines
9.4 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package oracle turns several sources into ONE price, or into a refusal.
//
// pkg/source fetches; this decides whether what came back may be believed. The
// split matters because the two answer different questions — "what does Kraken
// say" is a fetch, "what is BTC worth, well enough to move money on" is a
// judgement — and only the second one can say no.
//
// It exists because a price that is merely AVAILABLE is not a price you may
// credit a customer with. The service in this repo answers from CoinGecko alone
// behind an hour-long cache, which is right for a ticker and wrong for a
// deposit: one upstream is one outage and one bad tick away from valuing every
// deposit in the estate, and an hour-old quote for BTC is routinely tens of
// dollars stale.
//
// Every one of these is a REFUSAL, never a fallback:
//
// fewer than MinSources answered one venue is a single point of failure
// AND a single point of lie, and nothing
// downstream can tell a wrong number from
// a right one
// they disagree by > MaxSpread one of them is wrong and there is no way
// to know which; picking the "better" one
// is the guess this package exists to avoid
// a source answered <= 0 a parse that produced a number by accident
//
// A refusal is safe for the caller that matters: a deposit left uncredited is
// retried, and the coin waits in custody. Crediting at a wrong price converts a
// customer's coin into the wrong number of dollars permanently, and no later
// pass can find it.
package oracle
import (
"context"
"errors"
"fmt"
"math"
"sort"
"strings"
"sync"
"time"
"github.com/luxfi/price/pkg/source"
)
// MinSources is how many INDEPENDENT sources must answer before a price counts.
const MinSources = 2
// MaxSpread is how far they may disagree, as a fraction of the low quote.
//
// 2% never blocks ordinary venue-to-venue drift — BTC, XRP and TON measured
// 0.008%, 0.034% and 0.164% apart across two venues — and refuses a stale feed
// or a fat-finger tick.
const MaxSpread = 0.02
// Scale is how many micro-cents make one cent.
//
// Prices are carried in MICRO-CENTS rather than cents because the difference is
// money: XRP trades at $1.04295 and TON at $1.3412, which as whole cents become
// $1.04 and $1.34 — under-crediting 0.28% and 0.09% on every deposit, $2.80 lost
// on a $1,000 XRP top-up. Whole cents are precise enough for BTC and nowhere
// near it for a sub-dollar coin, and a rail cannot have a precision that depends
// on which asset it is pricing.
const Scale = 1_000_000
// ErrNoPrice means nothing could be justified. It is deliberately ONE error for
// every refusal above: a caller's only correct response to any of them is to not
// act and try again, so distinguishing them would invite handling one by acting
// anyway.
var ErrNoPrice = errors.New("oracle: no price could be justified")
// Price is a justified price with the evidence for it.
//
// Spread and Sources are recorded rather than discarded because a decision made
// at a market rate has to be answerable months later — "what did you value my
// BTC at, and how did you know" is a question a fixed peg never has to answer.
type Price struct {
ID string // canonical asset id, e.g. "bitcoin"
MicroCents int64 // USD cents × Scale, per WHOLE unit
At time.Time // when the sources were read
Spread float64 // (high-low)/low across the sources that answered
Sources []string // which venues agreed, ascending by quote
}
// String renders the price in plain dollars, for a human and for a receipt. It
// is display only; never compute from it.
func (p Price) String() string {
return fmt.Sprintf("%d.%08d", p.MicroCents/(100*Scale), p.MicroCents%(100*Scale))
}
// Oracle combines sources into prices, or refuses.
type Oracle struct {
sources []source.Source
}
// New builds an Oracle over the given sources.
//
// It refuses fewer than MinSources, and any venue named twice, at CONSTRUCTION
// rather than at the first call — a rail configured with one opinion is a
// deployment mistake and the money path is the worst place to discover it. Two
// entries for one exchange satisfy the COUNT while providing one opinion, which
// is the exact property MinSources is meant to buy.
func New(sources ...source.Source) (*Oracle, error) {
if len(sources) < MinSources {
return nil, fmt.Errorf("oracle: %d source(s), need at least %d", len(sources), MinSources)
}
seen := map[string]bool{}
for _, s := range sources {
n := strings.ToLower(strings.TrimSpace(s.Name()))
if n == "" {
return nil, errors.New("oracle: a source with no name cannot be attributed")
}
if seen[n] {
return nil, fmt.Errorf("oracle: source %q configured twice — two names for one venue defeat the independence MinSources is for", s.Name())
}
seen[n] = true
}
return &Oracle{sources: append([]source.Source(nil), sources...)}, nil
}
// Prices quotes every id it can justify and OMITS the rest.
//
// It is batch-shaped because source.Fetch is: one request per venue for many
// assets, not many requests. An id that cannot be justified is absent from the
// result rather than zero — a zero price is a number, and a number is something
// a caller can accidentally use.
//
// Sources are read CONCURRENTLY. A slow venue cannot hold up the rest beyond the
// caller's context, because this sits inside loops that run on a timer.
func (o *Oracle) Prices(ctx context.Context, ids []string) map[string]Price {
type reply struct {
name string
quotes map[string]source.Quote
}
replies := make([]reply, len(o.sources))
var wg sync.WaitGroup
for i, s := range o.sources {
if !s.Enabled() {
continue
}
wg.Add(1)
go func(i int, s source.Source) {
defer wg.Done()
q, err := s.Fetch(ctx, ids)
if err != nil {
return // a source that errors simply did not answer
}
replies[i] = reply{name: s.Name(), quotes: q}
}(i, s)
}
wg.Wait()
out := make(map[string]Price, len(ids))
for _, id := range ids {
var got []answer
for _, r := range replies {
if r.quotes == nil {
continue
}
q, ok := r.quotes[id]
// A non-positive price is NOT a price of nothing — it is a parse that
// failed quietly. Credited as zero it would look like dust rather than
// like the outage it is.
if !ok || q.PriceUSD <= 0 || math.IsInf(q.PriceUSD, 0) || math.IsNaN(q.PriceUSD) {
continue
}
got = append(got, answer{name: r.name, micro: microCents(q.PriceUSD)})
}
if p, ok := justify(id, got); ok {
out[id] = p
}
}
return out
}
// Price quotes ONE id, or says why not.
//
// The singular form returns an error where the batch form omits, because a
// caller asking about one asset is asking a question that deserves an answer:
// "absent from a map of one" is a worse way to learn you were refused.
func (o *Oracle) Price(ctx context.Context, id string) (Price, error) {
got := o.Prices(ctx, []string{id})
p, ok := got[id]
if !ok {
return Price{}, fmt.Errorf("%w: %s", ErrNoPrice, id)
}
return p, nil
}
type answer struct {
name string
micro int64
}
// justify applies the quorum and spread rules, and picks the number.
func justify(id string, got []answer) (Price, bool) {
if len(got) < MinSources {
return Price{}, false
}
sort.Slice(got, func(i, j int) bool { return got[i].micro < got[j].micro })
low, high := got[0].micro, got[len(got)-1].micro
if float64(high-low)/float64(low) > MaxSpread {
return Price{}, false
}
names := make([]string, 0, len(got))
for _, a := range got {
names = append(names, a.name)
}
// The median, and on an EVEN count the LOWER of the two middles rather than
// their average. The downward bias is deliberate: with two sources — the
// common case — this is the MINIMUM, so a venue quoting high can never pull a
// credit above what the conservative venue says the asset was worth. Whoever
// is being credited gets slightly less, never more, and the difference stays
// where it was rather than accruing to us through a number nobody can check.
return Price{
ID: id,
MicroCents: got[(len(got)-1)/2].micro,
At: time.Now().UTC(),
Spread: float64(high-low) / float64(low),
Sources: names,
}, true
}
// microCents converts a source's USD price to cents × Scale, truncating DOWN.
//
// source.Quote carries PriceUSD as a float64, so this cannot be exact: 65191.085
// is held as 65191.084999999997671…, and truncating costs at most ONE micro-cent
// per whole unit — 1e-8 dollars, or about 1e-10 dollars on a realistic BTC
// deposit. That is far below the dust threshold of every chain and is stated
// here so the bound is known rather than assumed. Carrying the venue's decimal
// STRING through source.Quote would make it exact; the cost of not doing so is
// this comment.
//
// Truncation is DOWN for the same reason the median takes the lower middle:
// never credit value that was not sent.
func microCents(usd float64) int64 {
if usd <= 0 || math.IsInf(usd, 0) || math.IsNaN(usd) {
return 0
}
m := math.Floor(usd * 100 * Scale)
// Beyond 2^63 the conversion is undefined behaviour in Go, and a price that
// large is a parse failure rather than an asset.
if m <= 0 || m >= math.MaxInt64 {
return 0
}
return int64(m)
}