mirror of
https://github.com/luxfi/graph.git
synced 2026-09-24 00:26:07 +00:00
Supply is what genesis minted, not what the P-Chain counts
platform.getCurrentSupply is the P-Chain's own counter. On Lux it reads 13.27 billion against a real supply of two trillion, so every market cap and fully diluted value built on it came out 150 times too small — $4.8M for a token whose diluted value is nearer $886M. I shipped that. This is the correction. The mint is a fact of block 0: 2,000,000,000,000 LUX allocated to one address, confirmed against the live chain at height 0. It is not exposed over RPC and summing every account is not a query, so it is declared beside the factory addresses, which are facts of the same kind. What moves is read live. That address holds 994.7B today, so a trillion has reached holders; the rest is undistributed and is no more circulating than the 2.5B bonded to validators. Circulating is the mint less both. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
+35
-17
@@ -68,6 +68,20 @@ type Config struct {
|
||||
// native trading source: a D-Chain trade is a consensus state transition, not an
|
||||
// EVM log, so it never appears on the EVM RPC. Empty => EVM-only (unchanged).
|
||||
DexRPC string
|
||||
// GenesisSupply is every native unit minted at block 0, in whole tokens —
|
||||
// 2000000000000 for Lux and for Zoo. It is a fact fixed at genesis, so it
|
||||
// belongs beside the factory addresses rather than being read: the alloc is
|
||||
// not exposed over RPC and summing every account is not a query.
|
||||
//
|
||||
// It is emphatically NOT platform.getCurrentSupply. That is the P-Chain's
|
||||
// own counter, which on Lux reads 13.27 billion against a real supply of
|
||||
// two trillion — a market cap 150 times too small, printed with confidence.
|
||||
GenesisSupply string
|
||||
// Treasury holds what has not been distributed. Its balance is read live,
|
||||
// because the whole point is that it goes down as tokens reach holders: at
|
||||
// genesis it held all 2T, today it holds 994.7B, and the difference is what
|
||||
// actually circulates.
|
||||
Treasury string
|
||||
}
|
||||
|
||||
// reorgDepth is how far the chain head may legitimately move backwards (deep
|
||||
@@ -98,15 +112,17 @@ const genesisBlockTag = "0x0"
|
||||
|
||||
// Indexer watches an EVM RPC and writes events to storage.
|
||||
type Indexer struct {
|
||||
rpc string
|
||||
poolManager string // lower-cased 0x9999 settlement address
|
||||
factoryV2 string // lower-cased canonical V2 factory (PairCreated trust root)
|
||||
factoryV3 string // lower-cased canonical V3 factory (PoolCreated trust root)
|
||||
native string // lower-cased wrapped native token — the unit `derivedETH` is denominated in
|
||||
label string // log prefix identifying this indexer (see Config.Label)
|
||||
startBlock uint64
|
||||
store *storage.Store
|
||||
client *http.Client
|
||||
rpc string
|
||||
poolManager string // lower-cased 0x9999 settlement address
|
||||
factoryV2 string // lower-cased canonical V2 factory (PairCreated trust root)
|
||||
factoryV3 string // lower-cased canonical V3 factory (PoolCreated trust root)
|
||||
genesisSupply string // whole native units minted at block 0
|
||||
treasury string // lower-cased holder of undistributed supply
|
||||
native string // lower-cased wrapped native token — the unit `derivedETH` is denominated in
|
||||
label string // log prefix identifying this indexer (see Config.Label)
|
||||
startBlock uint64
|
||||
store *storage.Store
|
||||
client *http.Client
|
||||
|
||||
// clob is the optional native D-Chain CLOB source (clob.go). Non-nil only when
|
||||
// Config.DexRPC is set; Run then drives it in parallel with the EVM poller.
|
||||
@@ -148,14 +164,16 @@ func NewWithConfig(cfg Config, store *storage.Store) *Indexer {
|
||||
pm = LXSettleAddress
|
||||
}
|
||||
idx := &Indexer{
|
||||
rpc: cfg.RPC,
|
||||
poolManager: strings.ToLower(pm),
|
||||
factoryV2: strings.ToLower(cfg.FactoryV2),
|
||||
factoryV3: strings.ToLower(cfg.FactoryV3),
|
||||
native: strings.ToLower(cfg.Native),
|
||||
label: cfg.Label,
|
||||
startBlock: cfg.StartBlock,
|
||||
store: store,
|
||||
rpc: cfg.RPC,
|
||||
poolManager: strings.ToLower(pm),
|
||||
factoryV2: strings.ToLower(cfg.FactoryV2),
|
||||
factoryV3: strings.ToLower(cfg.FactoryV3),
|
||||
native: strings.ToLower(cfg.Native),
|
||||
genesisSupply: cfg.GenesisSupply,
|
||||
treasury: strings.ToLower(cfg.Treasury),
|
||||
label: cfg.Label,
|
||||
startBlock: cfg.StartBlock,
|
||||
store: store,
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
|
||||
+87
-53
@@ -8,65 +8,96 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The native token lives on two chains at once, and a supply figure that reads
|
||||
// only one of them is wrong in a way nobody can see.
|
||||
// What a native token is worth depends on three numbers, and they live in three
|
||||
// different places. Getting the first one wrong is how a two-trillion-supply
|
||||
// token reported a market cap of $4.8M.
|
||||
//
|
||||
// The EVM side is what an ERC-20 reports: WLUX's totalSupply() is how much LUX
|
||||
// has been wrapped, not how much exists. The whole supply, and the part of it
|
||||
// locked in validators, are facts of the primary network — the P-Chain keeps
|
||||
// both, and answers in nLUX, nine decimals, not the eighteen an EVM uses.
|
||||
// minted — every unit created at block 0. A fact of genesis, not a
|
||||
// query: the alloc is not exposed over RPC and summing every
|
||||
// account is not something a node will do for you.
|
||||
// undistributed — what the treasury still holds, read live, because it falls
|
||||
// as tokens reach holders. Lux minted 2T to one address and
|
||||
// that address holds 994.7B today; the trillion that left is
|
||||
// exactly the point.
|
||||
// staked — bonded to a validator on the primary network. It has reached
|
||||
// holders but cannot be sold without unstaking first.
|
||||
//
|
||||
// Fully diluted value is price times the whole supply. Market cap is price
|
||||
// times what actually circulates, which is the supply less what is staked: that
|
||||
// LUX is bonded to a validator and cannot be sold without unstaking first. The
|
||||
// figures are read from the chain, not configured, so they follow every
|
||||
// delegation and every reward without anyone editing a list.
|
||||
// Circulating is what is left, and it is the only sound basis for a market cap.
|
||||
// Fully diluted value uses the whole minted supply, which is why they differ.
|
||||
//
|
||||
// The trap worth naming: platform.getCurrentSupply is NOT the token's supply.
|
||||
// It is the P-Chain's own counter, and on Lux it reads 13.27 billion against a
|
||||
// real supply of two trillion. Trusting it put the market cap out by 150x.
|
||||
const nLUX = 1e9
|
||||
|
||||
// fmtUnits writes a whole-token amount without exponent or separators, so the
|
||||
// client parses it as a number rather than guessing at a format.
|
||||
func fmtUnits(v float64) string { return strconv.FormatFloat(v, 'f', -1, 64) }
|
||||
|
||||
// NativeSupply is what the primary network says about its own token.
|
||||
// NativeSupply is what the chain says about its own token, in whole units.
|
||||
type NativeSupply struct {
|
||||
// Total is every unit in existence, staked or not.
|
||||
Total float64
|
||||
// Staked is bonded to a validator or a delegation, and cannot be sold
|
||||
// while it is.
|
||||
// Minted is every unit created at genesis.
|
||||
Minted float64
|
||||
// Undistributed is what the treasury still holds.
|
||||
Undistributed float64
|
||||
// Staked is bonded to a validator.
|
||||
Staked float64
|
||||
}
|
||||
|
||||
// Circulating is what can actually change hands.
|
||||
func (n NativeSupply) Circulating() float64 { return n.Total - n.Staked }
|
||||
|
||||
// primaryNetworkID is the ID every chain's own primary network answers to. It
|
||||
// is the same string on every network we run; the chain differs, the primary
|
||||
// network's identifier does not.
|
||||
const primaryNetworkID = "11111111111111111111111111111111LpoYY"
|
||||
|
||||
// readNativeSupply asks the P-Chain what exists and what is bonded.
|
||||
//
|
||||
// The endpoint is derived from the EVM one the indexer already holds: the two
|
||||
// chains sit side by side under the same node — /v1/bc/C/rpc and /v1/bc/P — so
|
||||
// there is nothing new to configure and nothing that can drift out of step with
|
||||
// the chain being indexed.
|
||||
func (idx *Indexer) readNativeSupply(ctx context.Context) (NativeSupply, bool) {
|
||||
base, ok := platformEndpoint(idx.rpc)
|
||||
if !ok {
|
||||
return NativeSupply{}, false
|
||||
func (n NativeSupply) Circulating() float64 {
|
||||
c := n.Minted - n.Undistributed - n.Staked
|
||||
if c < 0 {
|
||||
return 0
|
||||
}
|
||||
total, ok := idx.platformCall(ctx, base, "platform.getCurrentSupply", map[string]any{}, "supply")
|
||||
if !ok {
|
||||
return NativeSupply{}, false
|
||||
}
|
||||
// A chain with no validators of its own answers zero, which is honest: it
|
||||
// has nothing staked, so all of its supply circulates.
|
||||
staked, _ := idx.platformCall(ctx, base, "platform.getTotalStake",
|
||||
map[string]any{"subnetID": primaryNetworkID}, "stake")
|
||||
return NativeSupply{Total: total, Staked: staked}, true
|
||||
return c
|
||||
}
|
||||
|
||||
// platformEndpoint turns a C-Chain RPC URL into its P-Chain sibling.
|
||||
// primaryNetworkID is the ID every chain's own primary network answers to.
|
||||
const primaryNetworkID = "11111111111111111111111111111111LpoYY"
|
||||
|
||||
// readNativeSupply gathers the three figures.
|
||||
func (idx *Indexer) readNativeSupply(ctx context.Context) (NativeSupply, bool) {
|
||||
minted, err := strconv.ParseFloat(idx.genesisSupply, 64)
|
||||
if err != nil || minted <= 0 {
|
||||
return NativeSupply{}, false // no declared supply ⇒ say nothing
|
||||
}
|
||||
out := NativeSupply{Minted: minted}
|
||||
|
||||
if idx.treasury != "" {
|
||||
if bal, ok := idx.nativeBalance(ctx, idx.treasury); ok {
|
||||
out.Undistributed = bal
|
||||
}
|
||||
}
|
||||
if base, ok := platformEndpoint(idx.rpc); ok {
|
||||
// A chain with no validators of its own answers zero, which is honest.
|
||||
out.Staked, _ = idx.platformCall(ctx, base, "platform.getTotalStake",
|
||||
map[string]any{"subnetID": primaryNetworkID}, "stake")
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// nativeBalance reads an account's native balance in whole units.
|
||||
func (idx *Indexer) nativeBalance(ctx context.Context, addr string) (float64, bool) {
|
||||
raw, err := idx.rpcCall(ctx, "eth_getBalance", []interface{}{addr, "latest"})
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
var hex string
|
||||
if json.Unmarshal(raw, &hex) != nil {
|
||||
return 0, false
|
||||
}
|
||||
n, ok := new(big.Int).SetString(strings.TrimPrefix(hex, "0x"), 16)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
f, _ := new(big.Float).Quo(new(big.Float).SetInt(n), big.NewFloat(1e18)).Float64()
|
||||
return f, true
|
||||
}
|
||||
|
||||
// platformEndpoint turns a C-Chain RPC URL into its P-Chain sibling. The two
|
||||
// chains sit beside each other under the same node, so what is staked is one
|
||||
// derived URL away and nothing needs configuring.
|
||||
func platformEndpoint(evmRPC string) (string, bool) {
|
||||
i := strings.Index(evmRPC, "/bc/")
|
||||
if i < 0 {
|
||||
@@ -97,27 +128,30 @@ func (idx *Indexer) platformCall(ctx context.Context, url, method string, params
|
||||
return f, true
|
||||
}
|
||||
|
||||
// publishNativeSupply records what the primary network says about its own token
|
||||
// on the wrapped native token's entity — the one a token page opens for LUX.
|
||||
// publishNativeSupply records the figures on the wrapped native token — the
|
||||
// entity a token page opens for LUX.
|
||||
//
|
||||
// Written each pass rather than once at start: supply grows with every staking
|
||||
// reward and the staked figure moves with every delegation, so a value read at
|
||||
// boot would be quietly stale within the hour.
|
||||
// Written each pass rather than once: the treasury balance falls as tokens are
|
||||
// distributed and the staked figure moves with every delegation, so a value
|
||||
// read at boot would be quietly stale within the hour.
|
||||
func (idx *Indexer) publishNativeSupply(ctx context.Context) {
|
||||
if idx.native == "" {
|
||||
return
|
||||
}
|
||||
n, ok := idx.readNativeSupply(ctx)
|
||||
if !ok || n.Total <= 0 {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
tok := idx.store.TokensRaw()[idx.native]
|
||||
if tok == nil {
|
||||
return
|
||||
}
|
||||
tok.TotalSupply = fmtUnits(n.Total)
|
||||
tok.Staked = fmtUnits(n.Staked)
|
||||
idx.logf("[supply] native %s: %s total, %s staked, %s circulating",
|
||||
tok.Symbol, fmtUnits(n.Total), fmtUnits(n.Staked), fmtUnits(n.Circulating()))
|
||||
tok.TotalSupply = fmtUnits(n.Minted)
|
||||
// One field for everything that exists but cannot be sold, because that is
|
||||
// the single quantity the market-cap subtraction needs.
|
||||
tok.Staked = fmtUnits(n.Staked + n.Undistributed)
|
||||
idx.store.SeedToken(idx.native, tok)
|
||||
idx.logf("[supply] native %s: %s minted, %s undistributed, %s staked, %s circulating",
|
||||
tok.Symbol, fmtUnits(n.Minted), fmtUnits(n.Undistributed),
|
||||
fmtUnits(n.Staked), fmtUnits(n.Circulating()))
|
||||
}
|
||||
|
||||
+31
-12
@@ -2,22 +2,41 @@ package indexer
|
||||
|
||||
import "testing"
|
||||
|
||||
// Staked units exist but cannot be sold. Circulating is what is left, and it is
|
||||
// the difference between a market cap and a fully diluted value — report the
|
||||
// diluted figure as market cap and you overstate it by everything bonded to a
|
||||
// validator. Lux had 2.5B of 13.27B staked when this was written: an 18.8%
|
||||
// error, silently.
|
||||
// Circulating is what is left after everything that exists but cannot be sold:
|
||||
// what the treasury has not distributed, and what is bonded to a validator. It
|
||||
// is the difference between a market cap and a fully diluted value.
|
||||
func TestCirculatingExcludesStake(t *testing.T) {
|
||||
n := NativeSupply{Total: 13_272_095_200, Staked: 2_500_000_002}
|
||||
if got := n.Circulating(); got != 10_772_095_198 {
|
||||
t.Errorf("circulating = %.0f, want 10772095198", got)
|
||||
// Lux as it actually stands: 2T minted, 994.7B still in the treasury,
|
||||
// 2.5B bonded. Reading the P-Chain's own counter instead reported 13.27B
|
||||
// as the supply and priced the token 150x under.
|
||||
n := NativeSupply{Minted: 2_000_000_000_000, Undistributed: 994_738_895_226, Staked: 2_500_000_002}
|
||||
if got := n.Circulating(); got != 1_002_761_104_772 {
|
||||
t.Errorf("circulating = %.0f, want 1002761104772", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Undistributed supply is not circulating supply. A treasury holding half the
|
||||
// mint would otherwise double the reported market cap.
|
||||
func TestTreasuryIsNotCirculating(t *testing.T) {
|
||||
n := NativeSupply{Minted: 1000, Undistributed: 500}
|
||||
if got := n.Circulating(); got != 500 {
|
||||
t.Errorf("circulating = %.0f, want 500", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A treasury that somehow exceeds the mint must not produce a negative supply,
|
||||
// which would render as a negative market cap.
|
||||
func TestCirculatingNeverNegative(t *testing.T) {
|
||||
n := NativeSupply{Minted: 100, Undistributed: 200}
|
||||
if got := n.Circulating(); got != 0 {
|
||||
t.Errorf("circulating = %.0f, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A chain with no validators of its own has nothing bonded, so all of its
|
||||
// supply circulates. That is a real answer, not a missing one.
|
||||
func TestNoStakeMeansAllCirculates(t *testing.T) {
|
||||
n := NativeSupply{Total: 1000}
|
||||
n := NativeSupply{Minted: 1000}
|
||||
if got := n.Circulating(); got != 1000 {
|
||||
t.Errorf("circulating = %.0f, want 1000", got)
|
||||
}
|
||||
@@ -42,10 +61,10 @@ func TestPlatformEndpointDerivesFromEVM(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Whole units, no exponent: 13.27 billion in scientific notation parses to a
|
||||
// Whole units, no exponent: two trillion in scientific notation parses to a
|
||||
// different number in some clients and to NaN in others.
|
||||
func TestUnitsAreWrittenPlainly(t *testing.T) {
|
||||
if got := fmtUnits(13_272_095_200); got != "13272095200" {
|
||||
t.Errorf("fmtUnits = %q, want 13272095200", got)
|
||||
if got := fmtUnits(2_000_000_000_000); got != "2000000000000" {
|
||||
t.Errorf("fmtUnits = %q, want 2000000000000", got)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user