mirror of
https://github.com/luxfi/indexer.git
synced 2026-09-24 00:25:37 +00:00
198 lines
4.8 KiB
Go
198 lines
4.8 KiB
Go
package explorer
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"log"
|
|
"log/slog"
|
|
"math"
|
|
"math/big"
|
|
"net/http"
|
|
"sort"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// giniCoefficient computes the Gini coefficient for a sorted slice of balances.
|
|
// Returns 0 for empty or zero-sum inputs. Values must be non-negative.
|
|
func giniCoefficient(balances []float64) float64 {
|
|
n := float64(len(balances))
|
|
if n == 0 {
|
|
return 0
|
|
}
|
|
sort.Float64s(balances)
|
|
sum := 0.0
|
|
for _, b := range balances {
|
|
sum += b
|
|
}
|
|
if sum == 0 {
|
|
return 0
|
|
}
|
|
weightedSum := 0.0
|
|
for i, b := range balances {
|
|
weightedSum += float64(i+1) * b
|
|
}
|
|
g := (2*weightedSum)/(n*sum) - (n+1)/n
|
|
if g < 0 {
|
|
g = 0
|
|
}
|
|
return g
|
|
}
|
|
|
|
type holderTier struct {
|
|
Tier string `json:"tier"`
|
|
Count int `json:"count"`
|
|
PctSupply string `json:"pct_supply"`
|
|
}
|
|
|
|
type distributionResponse struct {
|
|
GiniCoefficient float64 `json:"gini_coefficient"`
|
|
Top10HoldersPct string `json:"top_10_holders_pct"`
|
|
Top100HoldersPct string `json:"top_100_holders_pct"`
|
|
HolderTiers []holderTier `json:"holder_tiers"`
|
|
}
|
|
|
|
// queryTokenDistribution loads balances from the DB and computes distribution stats.
|
|
func queryTokenDistribution(ctx context.Context, db *sql.DB, table string, tokenAddr any) (*distributionResponse, error) {
|
|
rows, err := db.QueryContext(ctx,
|
|
fmt.Sprintf("SELECT value FROM %s WHERE token_contract_address_hash = ? AND value IS NOT NULL ORDER BY CAST(value AS REAL) DESC LIMIT 10000", table),
|
|
tokenAddr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var balances []float64
|
|
totalSupply := new(big.Float)
|
|
|
|
for rows.Next() {
|
|
var valStr string
|
|
if err := rows.Scan(&valStr); err != nil {
|
|
continue
|
|
}
|
|
bf, _, err := new(big.Float).Parse(valStr, 10)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
f, _ := bf.Float64()
|
|
if f <= 0 {
|
|
continue
|
|
}
|
|
balances = append(balances, f)
|
|
totalSupply.Add(totalSupply, bf)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(balances) == 0 {
|
|
return &distributionResponse{
|
|
GiniCoefficient: 0,
|
|
Top10HoldersPct: "0",
|
|
Top100HoldersPct: "0",
|
|
HolderTiers: []holderTier{},
|
|
}, nil
|
|
}
|
|
|
|
// balances already sorted DESC from SQL
|
|
totalF, _ := totalSupply.Float64()
|
|
|
|
// Top 10 and top 100 percentages.
|
|
top10Sum := 0.0
|
|
top100Sum := 0.0
|
|
for i, b := range balances {
|
|
if i < 10 {
|
|
top10Sum += b
|
|
}
|
|
if i < 100 {
|
|
top100Sum += b
|
|
}
|
|
}
|
|
|
|
// Tiers by % of supply: whale >1%, large 0.1-1%, medium 0.01-0.1%, small <0.01%
|
|
type tierAcc struct {
|
|
count int
|
|
sum float64
|
|
}
|
|
tiers := [4]tierAcc{} // 0=whale, 1=large, 2=medium, 3=small
|
|
for _, b := range balances {
|
|
pct := (b / totalF) * 100
|
|
switch {
|
|
case pct > 1:
|
|
tiers[0].count++
|
|
tiers[0].sum += b
|
|
case pct > 0.1:
|
|
tiers[1].count++
|
|
tiers[1].sum += b
|
|
case pct > 0.01:
|
|
tiers[2].count++
|
|
tiers[2].sum += b
|
|
default:
|
|
tiers[3].count++
|
|
tiers[3].sum += b
|
|
}
|
|
}
|
|
|
|
tierNames := [4]string{"whale (>1%)", "large (0.1-1%)", "medium (0.01-0.1%)", "small (<0.01%)"}
|
|
holderTiers := make([]holderTier, 0, 4)
|
|
for i, t := range tiers {
|
|
if t.count == 0 {
|
|
continue
|
|
}
|
|
holderTiers = append(holderTiers, holderTier{
|
|
Tier: tierNames[i],
|
|
Count: t.count,
|
|
PctSupply: fmt.Sprintf("%.1f", (t.sum/totalF)*100),
|
|
})
|
|
}
|
|
|
|
// Sort ascending for Gini calculation.
|
|
sorted := make([]float64, len(balances))
|
|
copy(sorted, balances)
|
|
|
|
gini := giniCoefficient(sorted)
|
|
gini = math.Round(gini*100) / 100
|
|
|
|
return &distributionResponse{
|
|
GiniCoefficient: gini,
|
|
Top10HoldersPct: fmt.Sprintf("%.1f", (top10Sum/totalF)*100),
|
|
Top100HoldersPct: fmt.Sprintf("%.1f", (top100Sum/totalF)*100),
|
|
HolderTiers: holderTiers,
|
|
}, nil
|
|
}
|
|
|
|
// handleTokenDistribution serves GET /v1/explorer/tokens/{address_hash}/distribution.
|
|
func (s *Service) handleTokenDistribution(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
addr := chi.URLParam(r, "address_hash")
|
|
dist, err := queryTokenDistribution(ctx, s.db, "address_current_token_balances", hexToBytes(addr))
|
|
if err != nil {
|
|
s.logger.Error("token distribution error", slog.String("error", err.Error()))
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to compute distribution"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dist)
|
|
}
|
|
|
|
// tokenDistribution serves GET /v1/explorer/tokens/{addr}/distribution (standalone mode).
|
|
func (ss *StandaloneServer) tokenDistribution(r *http.Request) (any, int) {
|
|
addr := r.PathValue("addr")
|
|
if !isValidHexAddr(addr) {
|
|
return map[string]string{"error": "invalid token address"}, 400
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
dist, err := queryTokenDistribution(ctx, ss.db, ss.t.balances, addr)
|
|
if err != nil {
|
|
log.Printf("[explorer] distribution error: %v", err)
|
|
return map[string]string{"error": "failed to compute distribution"}, 500
|
|
}
|
|
return dist, 200
|
|
}
|