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:
2026-08-13 06:46:37 -07:00
co-authored by Hanzo Dev
parent d7884c7a92
commit 413ed9a82c
3 changed files with 153 additions and 82 deletions
+35 -17
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
}