mirror of
https://github.com/luxfi/graph.git
synced 2026-09-24 00:26:07 +00:00
Every already-indexed token had a symbol and no supply, so market cap and fully diluted value printed a dash however much the asset traded. The read existed; nothing ever reached it. The cold-start guard treated a real symbol as proof the row was complete, so a token seen once was never asked about again — and the row it adopted dropped the supply field on the way through. One predicate now says whether a row still has something to ask the chain: a real symbol AND a supply. One resolver answers it — store first, contract for the rest, memoised per address — and one merge folds a fresh read over what is already known, so a reverting call never erases a symbol and never erases the value locked, volume and price the valuation pass put on the same row. That last one was live: SeedToken is INSERT OR REPLACE and the write was built from the contract read alone. And one unit. The contract reports base units, the native coin's genesis figure is already whole, and both landed in the same field — a client that scales one and not the other is a valuation 10^18 out. The field is whole tokens now, converted where it is read, exactly: integer division and the remainder, because float64 gives up four orders of magnitude below a two-trillion-token supply. The backfill is what a start does rather than a flag to remember. Co-authored-by: Hanzo Dev <[email protected]>
255 lines
7.1 KiB
Go
255 lines
7.1 KiB
Go
// Package storage provides the data layer for graph.
|
|
//
|
|
// SQLite WAL for production (default), in-memory maps for nosqlite builds.
|
|
// All reads are concurrent-safe. Writes are serialized.
|
|
package storage
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// Entity types of the day series. The indexer writes them, the day-data
|
|
// readers read them, and naming them once keeps the two ends from drifting.
|
|
const (
|
|
TokenDay = "TokenDayData"
|
|
PoolDay = "PoolDayData"
|
|
FactoryDay = "FactoryDayData"
|
|
)
|
|
|
|
// Seed types for data population.
|
|
// Used by the indexer and e2e tests to inject state.
|
|
|
|
type SeedFactoryData struct {
|
|
PoolCount int64
|
|
TxCount int64
|
|
TotalVolumeUSD string
|
|
TotalValueLockedUSD string
|
|
}
|
|
|
|
type SeedBundleData struct {
|
|
EthPriceUSD string
|
|
}
|
|
|
|
type SeedTokenData struct {
|
|
Symbol string
|
|
Name string
|
|
Decimals int64
|
|
// TotalSupply is every unit that exists, in WHOLE tokens, as text — an
|
|
// exact decimal, because a supply reaches 2^256-1 and no float holds it.
|
|
// Whole rather than base units so a price multiplies it directly: it is
|
|
// what turns a price into a fully diluted value, and a reader scaling one
|
|
// source and not another is how a valuation lands 10^18 out.
|
|
TotalSupply string
|
|
// Staked is the part of a NATIVE token's supply bonded to a validator, in
|
|
// whole units. It is the difference between fully diluted value and market
|
|
// cap: staked units exist but cannot be sold. Empty for ordinary ERC-20s,
|
|
// which have no such notion.
|
|
Staked string
|
|
VolumeUSD string
|
|
TotalValueLockedUSD string
|
|
DerivedETH string
|
|
TxCount int64
|
|
}
|
|
|
|
type SeedPoolData struct {
|
|
Token0 string
|
|
Token1 string
|
|
FeeTier int64
|
|
TotalValueLockedUSD string
|
|
VolumeUSD string
|
|
Token0Price string
|
|
Token1Price string
|
|
TxCount int64
|
|
}
|
|
|
|
// SeedSwapData is one trade.
|
|
//
|
|
// Timestamp is the UNIX SECOND its block was mined, matching the subgraph wire
|
|
// field of the same name — a swap's time is a time, and every consumer (trade
|
|
// history, the day series) reads it as one.
|
|
//
|
|
// Block is that block's number. It is also what tells a row written by an older
|
|
// build apart from a current one: that build stored the block NUMBER in
|
|
// Timestamp and left Block unwritten, and no swap is ever mined in block 0, so
|
|
// Block == 0 identifies exactly the rows whose time still needs reading from
|
|
// the chain (see healSwapTimes).
|
|
type SeedSwapData struct {
|
|
Timestamp int64
|
|
Block uint64
|
|
Pool string
|
|
Amount0 string
|
|
Amount1 string
|
|
AmountUSD string
|
|
Sender string
|
|
}
|
|
|
|
// Dated reports whether Timestamp is a time. A row without a block was written
|
|
// before swaps carried one, so what it holds is a block number and the trade
|
|
// belongs to no day until healSwapTimes reads the real one. Its amounts are
|
|
// still amounts — an undated trade counts towards volume, just not towards a
|
|
// particular day.
|
|
func (d *SeedSwapData) Dated() bool { return d.Block != 0 && d.Timestamp > 0 }
|
|
|
|
// page applies a list query's three arguments — where, order, first — in the
|
|
// one order that answers the question asked: match, then sort the matches, then
|
|
// cut. Every collection read goes through it so no two of them can disagree
|
|
// about what `first: 90, orderBy: date` means.
|
|
func page(results []interface{}, limit int, orderBy, orderDirection string, where map[string]interface{}) []interface{} {
|
|
results = FilterResults(results, where)
|
|
sortResults(results, orderBy, orderDirection)
|
|
if len(results) > limit {
|
|
results = results[:limit]
|
|
}
|
|
return results
|
|
}
|
|
|
|
// sortResults sorts a slice of map[string]interface{} by the named field.
|
|
func sortResults(results []interface{}, orderBy, orderDirection string) {
|
|
if orderBy == "" || len(results) == 0 {
|
|
return
|
|
}
|
|
desc := strings.EqualFold(orderDirection, "desc")
|
|
sort.SliceStable(results, func(i, j int) bool {
|
|
mi, _ := results[i].(map[string]interface{})
|
|
mj, _ := results[j].(map[string]interface{})
|
|
if mi == nil || mj == nil {
|
|
return false
|
|
}
|
|
vi := mi[orderBy]
|
|
vj := mj[orderBy]
|
|
cmp := compareValues(vi, vj)
|
|
if desc {
|
|
return cmp > 0
|
|
}
|
|
return cmp < 0
|
|
})
|
|
}
|
|
|
|
// compareValues compares two interface{} values. If both parse as floats,
|
|
// compares numerically. Otherwise falls back to string comparison.
|
|
func compareValues(a, b interface{}) int {
|
|
sa := fmt.Sprint(a)
|
|
sb := fmt.Sprint(b)
|
|
fa, errA := strconv.ParseFloat(sa, 64)
|
|
fb, errB := strconv.ParseFloat(sb, 64)
|
|
if errA == nil && errB == nil {
|
|
if fa < fb {
|
|
return -1
|
|
}
|
|
if fa > fb {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
if sa < sb {
|
|
return -1
|
|
}
|
|
if sa > sb {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// FilterResults filters a slice of map results by where conditions.
|
|
// Supports exact match and _gte, _lte, _gt, _lt suffixes for numeric comparisons.
|
|
//
|
|
// The result is always non-nil: filtering everything out yields an empty list,
|
|
// never nil. A nil slice encodes as JSON `null`, and a GraphQL list field that
|
|
// answers `null` reads as "this collection does not exist" (broken subgraph)
|
|
// rather than "nothing matched" — see ListByType.
|
|
func FilterResults(results []interface{}, where map[string]interface{}) []interface{} {
|
|
if len(where) == 0 {
|
|
return results
|
|
}
|
|
out := []interface{}{}
|
|
for _, r := range results {
|
|
m, ok := r.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
if matchesWhere(m, where) {
|
|
out = append(out, r)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// equalValues reports equality for a `where` exact match. Text compares without
|
|
// regard to case: an address is the same address checksummed or lower-cased, and
|
|
// both spellings reach us — the store writes the lower-cased form the RPC
|
|
// returns, while a client hands back whatever its wallet or URL carried.
|
|
func equalValues(a, b interface{}) bool {
|
|
sa, aok := a.(string)
|
|
sb, bok := b.(string)
|
|
if aok && bok {
|
|
if _, err := strconv.ParseFloat(sa, 64); err != nil {
|
|
return strings.EqualFold(sa, sb)
|
|
}
|
|
}
|
|
return compareValues(a, b) == 0
|
|
}
|
|
|
|
func matchesWhere(m map[string]interface{}, where map[string]interface{}) bool {
|
|
for k, v := range where {
|
|
var field, op string
|
|
switch {
|
|
case strings.HasSuffix(k, "_gte"):
|
|
field = k[:len(k)-4]
|
|
op = "gte"
|
|
case strings.HasSuffix(k, "_lte"):
|
|
field = k[:len(k)-4]
|
|
op = "lte"
|
|
case strings.HasSuffix(k, "_gt"):
|
|
field = k[:len(k)-3]
|
|
op = "gt"
|
|
case strings.HasSuffix(k, "_lt"):
|
|
field = k[:len(k)-3]
|
|
op = "lt"
|
|
default:
|
|
field = k
|
|
op = "eq"
|
|
}
|
|
|
|
val, exists := m[field]
|
|
if !exists {
|
|
return false
|
|
}
|
|
// A reference field holds the entity it points at, so that a client can
|
|
// select through it (`tokenDayDatas { token { id } }`). The filter names
|
|
// the referenced id — `where: {token: "0x…"}` — so compare against that.
|
|
if ref, ok := val.(map[string]interface{}); ok {
|
|
if id, ok := ref["id"]; ok {
|
|
val = id
|
|
}
|
|
}
|
|
|
|
cmp := compareValues(val, v)
|
|
switch op {
|
|
case "eq":
|
|
if !equalValues(val, v) {
|
|
return false
|
|
}
|
|
case "gte":
|
|
if cmp < 0 {
|
|
return false
|
|
}
|
|
case "lte":
|
|
if cmp > 0 {
|
|
return false
|
|
}
|
|
case "gt":
|
|
if cmp <= 0 {
|
|
return false
|
|
}
|
|
case "lt":
|
|
if cmp >= 0 {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
}
|