feat(world/defi): DefiLlama-scale DeFi dashboard as the crypto→DeFi variant

Replace the crypto variant's trad-markets panels with a DefiLlama-shaped DeFi
board over Lux's OWN chains + the bridge-supported chain universe. Sovereign
data — no external DefiLlama dependency for our own chains.

Backend (BFF, internal/world):
- /v1/world/defi/{overview,chains,flows,protocols} — cached, never-5xx.
- 184-chain bridge catalog embedded (go:embed defi_chains.json), generated from
  luxfi/assets by scripts/gen-defi-chains.go; logos via assets.lux.network.
- Live per-chain metrics from explorer.lux.network (block height, total txns,
  addresses) for the 6 Lux-ecosystem EVM chains; TPS + block time COMPUTED from
  real counter deltas; USD TVL best-effort from the amm subgraph.
- Honesty: every USD figure the explorer hardcodes to null stays null
  (tvlProvenance says so); bridge-flow arcs flagged modeled:true. Nothing faked.

Frontend (src):
- DefiBoardPanel: hero aggregates + sortable/searchable 190-row chain table
  (content-visibility virtualization) + top-AMM-pools; Lux-cyan accents.
- Bridge-flow arcs on the globe (new bridgeFlows DeckGL layer, Lux hub ↔
  counterparties; real-weighted natives vs modeled externals).
- crypto variant now leads with the DeFi board; markets panels off by default.

Tests: 7 Go tests (embed, honesty invariants, sampler, mock-explorer full path,
routes) + 4 Playwright e2e (mounts/190 rows, sort+search, honest "—"/Live
filter, flows fetched). Verified live against explorer.lux.network (Lux 1.10M
blocks / 20.6k txns, 16 real AMM pools).

Stacked on feat/analyst-reward-signals; deploy AFTER the cloud + AI world views.

Claude-Session: https://claude.ai/code/session_015Z1iLf7QBrq1LhignJrzDw
This commit is contained in:
hanzo-dev
2026-07-16 20:07:27 -07:00
parent 685b3980c5
commit 42062bcb29
16 changed files with 4032 additions and 48 deletions
+160
View File
@@ -0,0 +1,160 @@
import { expect, test, type Page, type Route } from '@playwright/test';
/**
* DeFi variant e2e — the crypto→DeFi board asserted against the real DOM, with the
* /v1/world/defi/* backend stubbed (the vite dev server serves only the SPA). It
* proves the deliverables: the board mounts, the hero populates, the full 190-chain
* table renders + sorts + filters, honest "—" shows where a metric is absent, and
* the globe's bridge-flow layer is fetched. Fixtures mirror the Go BFF contract.
*/
const NATIVE = [
{ slug: 'lux', name: 'Lux Network', symbol: 'LUX', txns: 20618, blockHeight: 1096461, addresses: 67, tps: 0, live: true, native: true },
{ slug: 'zoo', name: 'Zoo Network', symbol: 'ZOO', txns: 12323, blockHeight: 13369, addresses: 53, tps: 0, live: true, native: true },
{ slug: 'hanzo', name: 'Hanzo Network', symbol: 'AI', txns: 10, blockHeight: 11, addresses: 10, tps: 0, live: true, native: true },
{ slug: 'pars', name: 'Pars Network', symbol: 'PARS', txns: 58, blockHeight: 63, addresses: 11, tps: 0, live: true, native: true },
{ slug: 'spc', name: 'Sparkle Pony', symbol: 'SPC', txns: 2, blockHeight: 13, addresses: 2, tps: 0, live: true, native: true },
{ slug: 'dex', name: 'Lux DEX', symbol: 'LUX', txns: null, blockHeight: null, addresses: null, tps: null, live: false, native: true },
];
function bridgeRows() {
// 184 bridge-supported chains (identity only). ethereum/bitcoin explicitly present
// so the search assertion targets a known slug.
const seed = ['ethereum', 'bitcoin', 'solana', 'polygon', 'arbitrum', 'base', 'avalanchec', 'binance', 'optimism'];
const rows = [] as unknown[];
for (let i = 0; i < 184; i++) {
const slug = i < seed.length ? seed[i] : `chain${i}`;
rows.push({
slug, name: slug.charAt(0).toUpperCase() + slug.slice(1), symbol: slug.slice(0, 4).toUpperCase(),
logo: `https://assets.lux.network/blockchains/${slug}/info/logo.png`,
native: false, bridge: true, live: false,
blockHeight: null, txns: null, addresses: null, tps: null, blockTime: null, tvlUsd: null, status: 'active',
});
}
return rows;
}
function chainsFixture() {
const chains = [
...NATIVE.map((n) => ({
slug: n.slug, name: n.name, symbol: n.symbol, logo: '', explorer: 'https://explore.lux.network',
native: true, bridge: true, live: n.live,
blockHeight: n.blockHeight, txns: n.txns, addresses: n.addresses, tps: n.tps, blockTime: null, tvlUsd: null, status: 'active',
})),
...bridgeRows(),
];
return {
updatedAt: new Date().toISOString(), chainCount: chains.length, nativeCount: 6, bridgeCount: 184,
liveCount: 5, metricsSource: 'https://explorer.lux.network', tvlProvenance: 'unavailable', chains,
};
}
const OVERVIEW = {
updatedAt: new Date().toISOString(), chainCount: 190, nativeCount: 6, bridgeCount: 184, liveCount: 5,
totalTxns: 33011, totalBlocks: 1109917, totalAddresses: 143, aggregateTps: 0, totalTvlUsd: null, volume24hUsd: null,
metricsSource: 'https://explorer.lux.network', tvlProvenance: 'unavailable',
topChains: NATIVE.filter((n) => n.live).map((n) => ({ slug: n.slug, name: n.name, symbol: n.symbol, txns: n.txns ?? 0, tps: n.tps, tvlUsd: null, live: n.live })),
};
const FLOWS = {
updatedAt: new Date().toISOString(), modeled: true, hubSlug: 'lux',
flows: [
{ fromSlug: 'lux', toSlug: 'zoo', fromLat: 37.77, fromLon: -122.42, toLat: 40.71, toLon: -74.01, weight: 1, label: 'LUX ⇄ ZOO', realFlow: true },
{ fromSlug: 'lux', toSlug: 'ethereum', fromLat: 37.77, fromLon: -122.42, toLat: 41, toLon: -74, weight: 0.85, label: 'LUX ⇄ Ethereum', realFlow: false },
],
};
const PROTOCOLS = {
updatedAt: new Date().toISOString(), metricsSource: 'https://explorer.lux.network', poolCount: 16,
pools: [
{ chain: 'Lux Network', pair: 'USDC/USDT', token0: 'USDC', token1: 'USDT', tvlUsd: null, volUsd: null },
{ chain: 'Lux Network', pair: 'WLUX/USDT', token0: 'WLUX', token1: 'USDT', tvlUsd: null, volUsd: null },
],
};
async function stubDefi(page: Page): Promise<{ flowsRequested: () => boolean }> {
let flowsRequested = false;
await page.route('**/v1/world/**', (route: Route) => {
const url = route.request().url();
const json = (body: unknown) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
if (url.includes('/v1/world/defi/overview')) return json(OVERVIEW);
if (url.includes('/v1/world/defi/chains')) return json(chainsFixture());
if (url.includes('/v1/world/defi/flows')) { flowsRequested = true; return json(FLOWS); }
if (url.includes('/v1/world/defi/protocols')) return json(PROTOCOLS);
// Everything else the app polls: harmless empty payloads.
return json([]);
});
return { flowsRequested: () => flowsRequested };
}
async function openDefiVariant(page: Page): Promise<void> {
await page.addInitScript(() => localStorage.setItem('worldmonitor-variant', 'crypto'));
await page.goto('/?variant=crypto');
await page.waitForSelector('.panel', { timeout: 30_000 });
}
test('DeFi board mounts with hero + 190-chain table', async ({ page }) => {
await stubDefi(page);
await openDefiVariant(page);
const board = page.locator('.panel[data-panel="defi-board"]');
await expect(board).toBeVisible({ timeout: 30_000 });
// Hero: stat tiles populate and the chain count (190) is shown.
await expect(board.locator('.defi-hero .cloud-stat')).toHaveCount(6);
await expect(board.locator('.defi-hero')).toContainText('190');
await expect(board.locator('.defi-provenance')).toContainText('explorer.lux.network');
// The full universe renders (190 rows).
await expect(board.locator('.defi-row')).toHaveCount(190, { timeout: 30_000 });
// Native chains lead and are txns-sorted (Lux first, then Zoo).
const first = board.locator('.defi-row').first();
await expect(first).toContainText('Lux Network');
await expect(first.locator('.defi-badge-native')).toBeVisible();
});
test('sorting + searching the chain table', async ({ page }) => {
await stubDefi(page);
await openDefiVariant(page);
const board = page.locator('.panel[data-panel="defi-board"]');
await expect(board.locator('.defi-row')).toHaveCount(190);
// Sort by Chain name (asc) → first row becomes alphabetical, not Lux.
await board.locator('.defi-th[data-sort="name"]').click();
await expect(board.locator('.defi-th[data-sort="name"]')).toHaveClass(/active/);
await expect(board.locator('.defi-row').first()).not.toContainText('Lux Network');
// Search narrows to a single known slug.
await board.locator('.defi-search').fill('ethereum');
await expect(board.locator('.defi-row')).toHaveCount(1);
await expect(board.locator('.defi-row').first()).toContainText('Ethereum');
// Clearing restores the full set.
await board.locator('.defi-search').fill('');
await expect(board.locator('.defi-row')).toHaveCount(190);
});
test('honest "—" for absent metrics + Live filter', async ({ page }) => {
await stubDefi(page);
await openDefiVariant(page);
const board = page.locator('.panel[data-panel="defi-board"]');
await expect(board.locator('.defi-row')).toHaveCount(190);
// A bridge row (identity only) shows em-dashes for every metric — never a 0.
const bridgeRow = board.locator('.defi-row', { hasText: 'Bitcoin' }).first();
await expect(bridgeRow.locator('.defi-badge-bridge')).toBeVisible();
await expect(bridgeRow).toContainText('—');
// The "Live" filter collapses to just the reachable chains (5 live natives).
await board.locator('.defi-chip[data-filter="live"]').click();
await expect(board.locator('.defi-row')).toHaveCount(5);
});
test('bridge-flow layer is fetched for the globe', async ({ page }) => {
const { flowsRequested } = await stubDefi(page);
await openDefiVariant(page);
await expect(page.locator('.panel[data-panel="defi-board"]')).toBeVisible();
// The DeckGL map pulls /v1/world/defi/flows to draw the Lux hub↔counterparty arcs.
await expect.poll(() => flowsRequested(), { timeout: 30_000 }).toBe(true);
});
+105
View File
@@ -0,0 +1,105 @@
package world
import (
_ "embed"
"encoding/json"
"sort"
"strings"
)
// DeFi chain catalog — the bridge-supported universe (bridge.lux.network) plus
// Lux's own sovereign L1s. Two sources, one merged catalog:
//
// - defiChainsJSON: the 184 bridge-supported asset chains, generated from
// github.com/luxfi/assets by scripts/gen-defi-chains.go and embedded here so
// the binary carries no runtime dependency on the assets repo. Identity only
// (name/symbol/logo/explorer) — external chains we bridge to but do not index.
// - nativeChains: Lux, Lux DEX, Zoo, Hanzo, Pars, SPC — our own chains, each
// with a live RPC the BFF probes for real height/TPS and (via the explorer's
// amm subgraph) real TVL. These lead the board.
//
// Honesty: the catalog is identity; every live number is overlaid at request time
// from a real upstream and is null when we can't reach it — never invented here.
//go:embed defi_chains.json
var defiChainsJSON []byte
// catalogChain is one embedded bridge-supported chain (identity only).
type catalogChain struct {
Slug string `json:"slug"`
Name string `json:"name"`
Symbol string `json:"symbol"`
Decimals int `json:"decimals"`
Explorer string `json:"explorer,omitempty"`
Website string `json:"website,omitempty"`
Logo string `json:"logo"`
Type string `json:"type,omitempty"`
Status string `json:"status,omitempty"`
Tags []string `json:"tags,omitempty"`
}
// bridgeCatalog is the parsed, slug-sorted embedded set (parsed once at init).
var bridgeCatalog = func() []catalogChain {
var cs []catalogChain
if err := json.Unmarshal(defiChainsJSON, &cs); err != nil {
return nil
}
sort.SliceStable(cs, func(i, j int) bool { return cs[i].Slug < cs[j].Slug })
return cs
}()
// nativeChain is one of our own L1s: catalog identity + the explorer slug the BFF
// reads real metrics from, and a modeled globe position for the bridge-flow arcs.
// ExplorerSlug maps to the explorer's chains.yaml slug (Lux C-Chain is "cchain")
// so the /v1/indexer/{slug}/* metrics + amm-TVL join cleanly. No RPC host is kept:
// the explorer (explorer.lux.network) is the single metrics source — one way.
// Logo is intentionally empty; our chains have no assets.lux.network entry, so the
// board renders a symbol initial-chip (frontend) rather than a broken image.
type nativeChain struct {
Slug string
Name string
Symbol string
ChainID int64
ExplorerSlug string // slug on explorer.lux.network (Lux C-Chain → "cchain")
ExplorerURL string
HasAMM bool // amm subgraph present → attempt real USD TVL (else nil)
Lat, Lon float64 // modeled globe position for bridge-flow arcs
Hub bool // the bridge hub (Lux) — every flow is hub↔counterparty
}
// nativeChains is the sovereign set the board leads with. ExplorerSlug + HasAMM
// mirror the explorer's chains.yaml (cchain/zoo/hanzo/dex carry the amm subgraph).
// Coordinates are modeled (chains have no geography) — the flows envelope carries
// modeled:true so the globe layer never claims they're real.
var nativeChains = []nativeChain{
{Slug: "lux", Name: "Lux Network", Symbol: "LUX", ChainID: 96369, ExplorerSlug: "cchain", ExplorerURL: "https://explore.lux.network", HasAMM: true, Lat: 37.77, Lon: -122.42, Hub: true},
{Slug: "dex", Name: "Lux DEX", Symbol: "LUX", ChainID: 96370, ExplorerSlug: "dex", ExplorerURL: "https://explorer.dex.lux.network", HasAMM: true, Lat: 1.35, Lon: 103.82},
{Slug: "zoo", Name: "Zoo Network", Symbol: "ZOO", ChainID: 200200, ExplorerSlug: "zoo", ExplorerURL: "https://explore.zoo.ngo", HasAMM: true, Lat: 40.71, Lon: -74.01},
{Slug: "hanzo", Name: "Hanzo Network", Symbol: "AI", ChainID: 36963, ExplorerSlug: "hanzo", ExplorerURL: "https://explore.hanzo.ai", HasAMM: true, Lat: 35.68, Lon: 139.69},
{Slug: "pars", Name: "Pars Network", Symbol: "PARS", ChainID: 494949, ExplorerSlug: "pars", ExplorerURL: "https://explore.pars.network", Lat: 35.70, Lon: 51.42},
{Slug: "spc", Name: "Sparkle Pony", Symbol: "SPC", ChainID: 36911, ExplorerSlug: "spc", ExplorerURL: "https://explore.sparklepony.xyz", Lat: 48.85, Lon: 2.35},
}
// nativeSlugs is the set of our own slugs, for O(1) "is this ours" checks so the
// merged catalog never lists a native chain twice.
var nativeSlugs = func() map[string]bool {
m := make(map[string]bool, len(nativeChains))
for _, n := range nativeChains {
m[n.Slug] = true
}
return m
}()
// luxHub returns the bridge hub (Lux) — the fixed endpoint every bridge-flow arc
// connects to. Falls back to the first native chain if the Hub flag is ever unset.
func luxHub() nativeChain {
for _, n := range nativeChains {
if n.Hub {
return n
}
}
return nativeChains[0]
}
// normSymbol upper-cases and trims a token symbol for stable display/join.
func normSymbol(s string) string { return strings.ToUpper(strings.TrimSpace(s)) }
File diff suppressed because it is too large Load Diff
+634
View File
@@ -0,0 +1,634 @@
package world
import (
"context"
"net/http"
"net/url"
"sort"
"strconv"
"sync"
"time"
)
// DeFi dashboard BFF — the data plane behind world.hanzo.ai's crypto→DeFi variant
// (a DefiLlama-shaped board over Lux's OWN chains + the bridge-supported universe).
// Three public, unauthenticated, same-origin routes, all cached + never-5xx like
// the rest of /v1/world/*:
//
// GET /v1/world/defi/overview → totals + top chains + honest provenance
// GET /v1/world/defi/chains → the full merged catalog (native + 184 bridge)
// GET /v1/world/defi/flows → bridge-flow arcs for the globe (modeled)
// GET /v1/world/defi/protocols → top AMM pools where the explorer has them
//
// Sources (sovereign — no external DefiLlama dependency for our own chains):
// - explorer.lux.network /v1/indexer/{slug}/stats + /blocks (REAL: block
// height, total txns, addresses) and /v1/graph/{slug}/amm (USD TVL IF the
// subgraph populates it — today it does not, so TVL is honestly null).
// - the embedded 184-chain bridge catalog (identity only; bridge-supported).
//
// Honesty contract: every USD figure the explorer hardcodes to null STAYS null
// here (tvlProvenance says so); TPS + block time are COMPUTED from real counter
// deltas (nil until a baseline exists) — never invented.
// defiChainTimeout bounds each chain's explorer fetch. Longer than the cloud-map
// perChainTimeout (4s) because Lux C-Chain's /stats over ~1.1M indexed blocks can
// take several seconds cold; still well inside cachedJSON's 24s request deadline.
const defiChainTimeout = 9 * time.Second
// defiExplorerBase is the explorer REST/GraphQL origin. Override with EXPLORER_BASE.
func defiExplorerBase() string {
if b := env("EXPLORER_BASE", "LUX_EXPLORER_BASE"); b != "" {
return trimSlash(b)
}
return "https://explorer.lux.network"
}
// defiGraphHosts is the exact-host allowlist for the amm GraphQL POST (postJSON's
// SSRF boundary) — derived from defiExplorerBase so there is one source of truth.
func defiGraphHosts() map[string]bool {
m := map[string]bool{}
if u, err := url.Parse(defiExplorerBase()); err == nil && u.Hostname() != "" {
m[u.Hostname()] = true
}
return m
}
// ── live metrics (per native chain) ──────────────────────────────────────────
// chainMetric is the real, per-chain telemetry overlaid on a native catalog row.
// Pointer fields are null when we cannot honestly compute them (no baseline yet /
// upstream absent) so the wire carries null, never a fabricated zero.
type chainMetric struct {
Live bool
BlockHeight int64
TotalTxns int64
TotalBlocks int64
Addresses int64
GasUsed int64
TPS *float64
BlockTime *float64
TvlUSD *float64
}
// defiSample + defiRate: the TPS / block-time sampler. Real sustained rates come
// from the delta of the explorer's monotonic counters between two samples ≥ a real
// interval apart. The baseline only advances every ~20s so frequent collections
// (two endpoints on a cold cache) can't collapse dt into a noisy/zero rate.
type defiSample struct {
txns, blocks int64
at time.Time
}
var (
defiSampleMu sync.Mutex
defiSamples = map[string]defiSample{}
)
func defiRate(slug string, txns, blocks int64) (tps, blockTime *float64) {
defiSampleMu.Lock()
defer defiSampleMu.Unlock()
now := time.Now()
prev, ok := defiSamples[slug]
if !ok {
defiSamples[slug] = defiSample{txns, blocks, now}
return nil, nil
}
dt := now.Sub(prev.at).Seconds()
if dt >= 1 {
if d := txns - prev.txns; d >= 0 {
v := round2s(float64(d) / dt)
tps = &v
}
if d := blocks - prev.blocks; d > 0 {
v := round2s(dt / float64(d))
blockTime = &v
}
}
if dt >= 20 { // refresh the baseline only after a real interval
defiSamples[slug] = defiSample{txns, blocks, now}
}
return
}
// collectDefiMetrics fetches real telemetry for every native chain in parallel,
// each bounded by perChainTimeout so one unreachable chain can't stall the board.
// Never errors — an unreachable chain simply stays Live:false with zeroed counts.
func (s *Server) collectDefiMetrics(ctx context.Context) map[string]chainMetric {
out := make([]chainMetric, len(nativeChains))
var wg sync.WaitGroup
for i, nc := range nativeChains {
wg.Add(1)
go func(i int, nc nativeChain) {
defer wg.Done()
cctx, cancel := context.WithTimeout(ctx, defiChainTimeout)
defer cancel()
out[i] = s.fetchDefiChain(cctx, nc)
}(i, nc)
}
wg.Wait()
m := make(map[string]chainMetric, len(nativeChains))
for i, nc := range nativeChains {
m[nc.Slug] = out[i]
}
return m
}
// fetchDefiChain reads one native chain's real metrics from the explorer, degrading
// each field independently. Stats (counts) + latest-block (height) are the liveness
// signal; TPS/block time come from the sampler; TVL is best-effort from the amm
// subgraph and stays nil unless it returns a real positive USD figure.
func (s *Server) fetchDefiChain(ctx context.Context, nc nativeChain) chainMetric {
base := defiExplorerBase()
var m chainMetric
var stats struct {
TotalBlocks string `json:"total_blocks"`
TotalTransactions string `json:"total_transactions"`
TotalAddresses string `json:"total_addresses"`
TotalGasUsed string `json:"total_gas_used"`
}
if err := s.getJSON(ctx, base+"/v1/indexer/"+nc.ExplorerSlug+"/stats", nil, &stats); err == nil {
if stats.TotalBlocks != "" || stats.TotalTransactions != "" {
m.Live = true
m.TotalBlocks = atoi64(stats.TotalBlocks)
m.TotalTxns = atoi64(stats.TotalTransactions)
m.Addresses = atoi64(stats.TotalAddresses)
m.GasUsed = atoi64(stats.TotalGasUsed)
m.TPS, m.BlockTime = defiRate(nc.Slug, m.TotalTxns, m.TotalBlocks)
}
}
// True head height (total_blocks is a row count, not guaranteed == height).
var blocks struct {
Items []struct {
Height int64 `json:"height"`
} `json:"items"`
}
if err := s.getJSON(ctx, base+"/v1/indexer/"+nc.ExplorerSlug+"/blocks?items_count=1", nil, &blocks); err == nil && len(blocks.Items) > 0 {
m.BlockHeight = blocks.Items[0].Height
if m.BlockHeight > 0 {
m.Live = true
}
}
// Real USD TVL, best-effort: the amm subgraph's factory aggregate. Today the
// subgraph leaves totalValueLockedUSD empty, so this stays nil — honest, not 0.
if nc.HasAMM {
if tvl, ok := s.fetchAMMTvl(ctx, base, nc.ExplorerSlug); ok {
m.TvlUSD = &tvl
}
}
return m
}
// fetchAMMTvl POSTs the amm subgraph for the factory USD aggregate. ok=false on any
// error OR an empty/non-positive value (the current reality) — so USD TVL is never
// dressed up from an unpopulated column.
func (s *Server) fetchAMMTvl(ctx context.Context, base, slug string) (float64, bool) {
var resp struct {
Data struct {
Factories []struct {
TotalValueLockedUSD string `json:"totalValueLockedUSD"`
} `json:"factories"`
} `json:"data"`
}
body := map[string]string{"query": "{ factories(first:1){ totalValueLockedUSD } }"}
if err := s.postJSON(ctx, base+"/v1/graph/"+slug+"/amm/graphql", defiGraphHosts(), body, &resp); err != nil {
return 0, false
}
if len(resp.Data.Factories) == 0 {
return 0, false
}
v, err := strconv.ParseFloat(resp.Data.Factories[0].TotalValueLockedUSD, 64)
if err != nil || v <= 0 {
return 0, false
}
return v, true
}
// atoi64 parses a decimal counter string ("20618") to int64; 0 on any failure.
func atoi64(s string) int64 {
n, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return 0
}
return n
}
// ── wire types ───────────────────────────────────────────────────────────────
type defiChainRow struct {
Slug string `json:"slug"`
Name string `json:"name"`
Symbol string `json:"symbol"`
Logo string `json:"logo"`
Explorer string `json:"explorer,omitempty"`
ChainID int64 `json:"chainId,omitempty"`
Native bool `json:"native"`
Bridge bool `json:"bridge"`
Live bool `json:"live"`
BlockHeight *int64 `json:"blockHeight"`
Txns *int64 `json:"txns"`
Addresses *int64 `json:"addresses"`
TPS *float64 `json:"tps"`
BlockTime *float64 `json:"blockTime"`
TvlUSD *float64 `json:"tvlUsd"`
Status string `json:"status,omitempty"`
Tags []string `json:"tags,omitempty"`
}
type defiChainsResp struct {
UpdatedAt string `json:"updatedAt"`
ChainCount int `json:"chainCount"`
NativeCount int `json:"nativeCount"`
BridgeCount int `json:"bridgeCount"`
LiveCount int `json:"liveCount"`
MetricsSource string `json:"metricsSource"`
TvlProvenance string `json:"tvlProvenance"`
Chains []defiChainRow `json:"chains"`
}
// buildChainRows merges the native set (with live metrics) and the 184 bridge
// catalog (identity only) into the ordered board rows. Native chains lead, sorted
// by real transactions desc; the bridge universe follows, alphabetical.
func buildChainRows(m map[string]chainMetric) (rows []defiChainRow, live int) {
natives := make([]defiChainRow, 0, len(nativeChains))
for _, nc := range nativeChains {
cm := m[nc.Slug]
row := defiChainRow{
Slug: nc.Slug, Name: nc.Name, Symbol: nc.Symbol, Logo: "",
Explorer: nc.ExplorerURL, ChainID: nc.ChainID, Native: true, Bridge: true,
Live: cm.Live, Status: "active",
}
if cm.Live {
live++
row.BlockHeight = ptrIf(cm.BlockHeight, cm.BlockHeight > 0)
row.Txns = ptr64(cm.TotalTxns)
row.Addresses = ptr64(cm.Addresses)
row.TPS = cm.TPS
row.BlockTime = cm.BlockTime
row.TvlUSD = cm.TvlUSD
}
natives = append(natives, row)
}
sort.SliceStable(natives, func(i, j int) bool {
return deref64(natives[i].Txns) > deref64(natives[j].Txns)
})
bridge := make([]defiChainRow, 0, len(bridgeCatalog))
for _, c := range bridgeCatalog {
bridge = append(bridge, defiChainRow{
Slug: c.Slug, Name: c.Name, Symbol: c.Symbol, Logo: c.Logo,
Explorer: c.Explorer, Native: false, Bridge: true, Live: false,
Status: c.Status, Tags: c.Tags,
})
}
return append(natives, bridge...), live
}
func (s *Server) handleDefiChains(w http.ResponseWriter, r *http.Request) {
if preflight(w, r, "GET, OPTIONS") || methodNotGet(w, r) {
return
}
s.cachedJSON(
w, "defi-chains", "public, max-age=30, s-maxage=30, stale-while-revalidate=120",
30*time.Second, 5*time.Minute,
func(ctx context.Context) (any, error) {
rows, live := buildChainRows(s.collectDefiMetrics(ctx))
return defiChainsResp{
UpdatedAt: nowRFC(), ChainCount: len(rows), NativeCount: len(nativeChains),
BridgeCount: len(bridgeCatalog), LiveCount: live,
MetricsSource: defiExplorerBase(), TvlProvenance: tvlProvenance(rows),
Chains: rows,
}, nil
},
func(w http.ResponseWriter, _ error) {
rows, _ := buildChainRows(nil)
writeJSON(w, http.StatusOK, "", defiChainsResp{
UpdatedAt: nowRFC(), ChainCount: len(rows), NativeCount: len(nativeChains),
BridgeCount: len(bridgeCatalog), MetricsSource: defiExplorerBase(),
TvlProvenance: "unavailable", Chains: rows,
})
},
)
}
// ── overview ─────────────────────────────────────────────────────────────────
type defiTopChain struct {
Slug string `json:"slug"`
Name string `json:"name"`
Symbol string `json:"symbol"`
Txns int64 `json:"txns"`
TPS *float64 `json:"tps"`
TvlUSD *float64 `json:"tvlUsd"`
Live bool `json:"live"`
}
type defiOverviewResp struct {
UpdatedAt string `json:"updatedAt"`
ChainCount int `json:"chainCount"`
NativeCount int `json:"nativeCount"`
BridgeCount int `json:"bridgeCount"`
LiveCount int `json:"liveCount"`
TotalTxns int64 `json:"totalTxns"`
TotalBlocks int64 `json:"totalBlocks"`
TotalAddrs int64 `json:"totalAddresses"`
AggregateTPS *float64 `json:"aggregateTps"`
TotalTvlUSD *float64 `json:"totalTvlUsd"`
Volume24hUSD *float64 `json:"volume24hUsd"`
MetricsSource string `json:"metricsSource"`
TvlProvenance string `json:"tvlProvenance"`
TopChains []defiTopChain `json:"topChains"`
}
func (s *Server) handleDefiOverview(w http.ResponseWriter, r *http.Request) {
if preflight(w, r, "GET, OPTIONS") || methodNotGet(w, r) {
return
}
s.cachedJSON(
w, "defi-overview", "public, max-age=30, s-maxage=30, stale-while-revalidate=120",
30*time.Second, 5*time.Minute,
func(ctx context.Context) (any, error) {
return s.buildDefiOverview(s.collectDefiMetrics(ctx)), nil
},
func(w http.ResponseWriter, _ error) {
writeJSON(w, http.StatusOK, "", defiOverviewResp{
UpdatedAt: nowRFC(), ChainCount: len(nativeChains) + len(bridgeCatalog),
NativeCount: len(nativeChains), BridgeCount: len(bridgeCatalog),
MetricsSource: defiExplorerBase(), TvlProvenance: "unavailable",
})
},
)
}
func (s *Server) buildDefiOverview(m map[string]chainMetric) defiOverviewResp {
out := defiOverviewResp{
UpdatedAt: nowRFC(), ChainCount: len(nativeChains) + len(bridgeCatalog),
NativeCount: len(nativeChains), BridgeCount: len(bridgeCatalog),
MetricsSource: defiExplorerBase(),
}
var tpsSum, tvlSum float64
var haveTPS, haveTVL bool
tops := make([]defiTopChain, 0, len(nativeChains))
for _, nc := range nativeChains {
cm := m[nc.Slug]
if cm.Live {
out.LiveCount++
out.TotalTxns += cm.TotalTxns
out.TotalBlocks += cm.TotalBlocks
out.TotalAddrs += cm.Addresses
}
if cm.TPS != nil {
tpsSum += *cm.TPS
haveTPS = true
}
if cm.TvlUSD != nil {
tvlSum += *cm.TvlUSD
haveTVL = true
}
tops = append(tops, defiTopChain{
Slug: nc.Slug, Name: nc.Name, Symbol: nc.Symbol,
Txns: cm.TotalTxns, TPS: cm.TPS, TvlUSD: cm.TvlUSD, Live: cm.Live,
})
}
if haveTPS {
v := round2s(tpsSum)
out.AggregateTPS = &v
}
if haveTVL {
v := round2s(tvlSum)
out.TotalTvlUSD = &v
out.TvlProvenance = "explorer-amm"
} else {
out.TvlProvenance = "unavailable" // explorer hardcodes USD TVL to null today
}
// 24h USD volume is not exposed by the explorer (transactions_today + market
// fields are null) — honestly null rather than a fabricated figure.
out.Volume24hUSD = nil
sort.SliceStable(tops, func(i, j int) bool { return tops[i].Txns > tops[j].Txns })
if len(tops) > 8 {
tops = tops[:8]
}
out.TopChains = tops
return out
}
// ── bridge flows (globe arcs) ────────────────────────────────────────────────
type defiFlow struct {
FromSlug string `json:"fromSlug"`
ToSlug string `json:"toSlug"`
FromLat float64 `json:"fromLat"`
FromLon float64 `json:"fromLon"`
ToLat float64 `json:"toLat"`
ToLon float64 `json:"toLon"`
Weight float64 `json:"weight"`
Label string `json:"label"`
RealFlow bool `json:"realFlow"` // weight is a real activity proxy vs a modeled base
}
type defiFlowsResp struct {
UpdatedAt string `json:"updatedAt"`
Modeled bool `json:"modeled"`
HubSlug string `json:"hubSlug"`
Flows []defiFlow `json:"flows"`
}
// bridgeCounterparty is a modeled globe position for a major external bridge
// endpoint. Chains have no geography, so these coordinates are illustrative — the
// envelope's modeled:true is the honest disclosure.
type bridgeCounterparty struct {
slug, label string
lat, lon float64
weight float64 // modeled base weight for the arc
}
var bridgeCounterparties = []bridgeCounterparty{
{"ethereum", "Ethereum", 41.0, -74.0, 0.85},
{"bitcoin", "Bitcoin", 45.0, -100.0, 0.80},
{"solana", "Solana", 37.5, -122.0, 0.55},
{"binance", "BNB Chain", 22.3, 114.2, 0.55},
{"polygon", "Polygon", 19.0, 73.0, 0.45},
{"arbitrum", "Arbitrum", 51.5, -0.1, 0.45},
{"base", "Base", 30.3, -97.7, 0.40},
{"avalanchec", "Avalanche", 43.6, -79.4, 0.38},
{"optimism", "Optimism", 52.4, 4.9, 0.35},
}
func (s *Server) handleDefiFlows(w http.ResponseWriter, r *http.Request) {
if preflight(w, r, "GET, OPTIONS") || methodNotGet(w, r) {
return
}
s.cachedJSON(
w, "defi-flows", "public, max-age=60, s-maxage=60, stale-while-revalidate=240",
60*time.Second, 5*time.Minute,
func(ctx context.Context) (any, error) {
return s.buildDefiFlows(s.collectDefiMetrics(ctx)), nil
},
func(w http.ResponseWriter, _ error) {
writeJSON(w, http.StatusOK, "", s.buildDefiFlows(nil))
},
)
}
// buildDefiFlows arcs the Lux hub to every counterparty. Native counterparties are
// weighted by REAL relative transactions (realFlow:true); external majors carry a
// modeled base weight. modeled:true covers the modeled positions + external weights.
func (s *Server) buildDefiFlows(m map[string]chainMetric) defiFlowsResp {
hub := luxHub()
flows := make([]defiFlow, 0, len(nativeChains)+len(bridgeCounterparties))
var maxTxns int64 = 1
for _, nc := range nativeChains {
if nc.Hub {
continue
}
if t := m[nc.Slug].TotalTxns; t > maxTxns {
maxTxns = t
}
}
for _, nc := range nativeChains {
if nc.Hub {
continue
}
txns := m[nc.Slug].TotalTxns
w := 0.15 + 0.85*clampF(float64(txns)/float64(maxTxns), 0, 1)
flows = append(flows, defiFlow{
FromSlug: hub.Slug, ToSlug: nc.Slug,
FromLat: hub.Lat, FromLon: hub.Lon, ToLat: nc.Lat, ToLon: nc.Lon,
Weight: round2s(w), Label: hub.Symbol + " ⇄ " + nc.Symbol, RealFlow: txns > 0,
})
}
for _, cp := range bridgeCounterparties {
flows = append(flows, defiFlow{
FromSlug: hub.Slug, ToSlug: cp.slug,
FromLat: hub.Lat, FromLon: hub.Lon, ToLat: cp.lat, ToLon: cp.lon,
Weight: round2s(cp.weight), Label: hub.Symbol + " ⇄ " + cp.label, RealFlow: false,
})
}
sort.SliceStable(flows, func(i, j int) bool { return flows[i].Weight > flows[j].Weight })
return defiFlowsResp{UpdatedAt: nowRFC(), Modeled: true, HubSlug: hub.Slug, Flows: flows}
}
// ── protocols (top AMM pools) ────────────────────────────────────────────────
type defiPool struct {
Chain string `json:"chain"`
Pair string `json:"pair"`
Token0 string `json:"token0"`
Token1 string `json:"token1"`
TvlUSD *float64 `json:"tvlUsd"`
VolUSD *float64 `json:"volUsd"`
}
type defiProtocolsResp struct {
UpdatedAt string `json:"updatedAt"`
MetricsSource string `json:"metricsSource"`
PoolCount int `json:"poolCount"`
Pools []defiPool `json:"pools"`
}
func (s *Server) handleDefiProtocols(w http.ResponseWriter, r *http.Request) {
if preflight(w, r, "GET, OPTIONS") || methodNotGet(w, r) {
return
}
s.cachedJSON(
w, "defi-protocols", "public, max-age=60, s-maxage=60, stale-while-revalidate=240",
60*time.Second, 5*time.Minute,
func(ctx context.Context) (any, error) {
pools := s.collectDefiPools(ctx)
return defiProtocolsResp{
UpdatedAt: nowRFC(), MetricsSource: defiExplorerBase(),
PoolCount: len(pools), Pools: pools,
}, nil
},
func(w http.ResponseWriter, _ error) {
writeJSON(w, http.StatusOK, "", defiProtocolsResp{
UpdatedAt: nowRFC(), MetricsSource: defiExplorerBase(), Pools: []defiPool{},
})
},
)
}
// collectDefiPools queries each amm chain for its top pools. USD fields stay null
// where the subgraph leaves them empty (today's reality) — the pair identity is
// real; the numbers are shown only when real.
func (s *Server) collectDefiPools(ctx context.Context) []defiPool {
base := defiExplorerBase()
out := make([]defiPool, 0, 24)
for _, nc := range nativeChains {
if !nc.HasAMM {
continue
}
cctx, cancel := context.WithTimeout(ctx, defiChainTimeout)
var resp struct {
Data struct {
Pools []struct {
Token0 struct {
Symbol string `json:"symbol"`
} `json:"token0"`
Token1 struct {
Symbol string `json:"symbol"`
} `json:"token1"`
TotalValueLockedUSD string `json:"totalValueLockedUSD"`
VolumeUSD string `json:"volumeUSD"`
} `json:"pools"`
} `json:"data"`
}
body := map[string]string{"query": "{ pools(first:8, orderBy: totalValueLockedUSD, orderDirection: desc){ token0{ symbol } token1{ symbol } totalValueLockedUSD volumeUSD } }"}
err := s.postJSON(cctx, base+"/v1/graph/"+nc.ExplorerSlug+"/amm/graphql", defiGraphHosts(), body, &resp)
cancel()
if err != nil {
continue
}
for _, p := range resp.Data.Pools {
t0, t1 := normSymbol(p.Token0.Symbol), normSymbol(p.Token1.Symbol)
if t0 == "" && t1 == "" {
continue
}
out = append(out, defiPool{
Chain: nc.Name, Pair: t0 + "/" + t1, Token0: t0, Token1: t1,
TvlUSD: parsePosUSD(p.TotalValueLockedUSD), VolUSD: parsePosUSD(p.VolumeUSD),
})
}
}
return out
}
// ── small helpers ────────────────────────────────────────────────────────────
func tvlProvenance(rows []defiChainRow) string {
for _, r := range rows {
if r.TvlUSD != nil {
return "explorer-amm"
}
}
return "unavailable"
}
func parsePosUSD(s string) *float64 {
v, err := strconv.ParseFloat(s, 64)
if err != nil || v <= 0 {
return nil
}
r := round2s(v)
return &r
}
func ptr64(v int64) *int64 { return &v }
func ptrIf(v int64, ok bool) *int64 {
if !ok {
return nil
}
return &v
}
func deref64(p *int64) int64 {
if p == nil {
return 0
}
return *p
}
+251
View File
@@ -0,0 +1,251 @@
package world
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// TestDefiCatalogEmbed proves the embedded bridge-supported catalog parses and is
// complete: the 184 chains each carry an identity + a Lux-CDN logo. This is the
// "200 blockchains" universe the board renders even with zero network.
func TestDefiCatalogEmbed(t *testing.T) {
if len(bridgeCatalog) < 180 {
t.Fatalf("bridge catalog too small: %d (want ~184)", len(bridgeCatalog))
}
for _, c := range bridgeCatalog {
if c.Slug == "" || c.Name == "" {
t.Fatalf("catalog row missing identity: %+v", c)
}
if !strings.HasPrefix(c.Logo, "https://assets.lux.network/blockchains/") {
t.Fatalf("%s logo not a Lux-CDN URL: %q", c.Slug, c.Logo)
}
}
// No overlap between our sovereign chains and the bridge universe.
for _, c := range bridgeCatalog {
if nativeSlugs[c.Slug] {
t.Fatalf("native slug %q also in bridge catalog (double-listed)", c.Slug)
}
}
}
// TestBuildChainRows enforces the core honesty invariant: a row that is not live
// must carry NO fabricated metric (all pointers nil), and the 184 bridge chains
// are identity-only. Native chains lead, sorted by real transactions.
func TestBuildChainRows(t *testing.T) {
m := map[string]chainMetric{
"lux": {Live: true, BlockHeight: 1096461, TotalTxns: 20618, Addresses: 67},
"zoo": {Live: true, BlockHeight: 13369, TotalTxns: 12323, Addresses: 53},
"dex": {}, // unreachable → not live
"pars": {Live: true, BlockHeight: 63, TotalTxns: 58},
}
rows, live := buildChainRows(m)
if len(rows) != len(nativeChains)+len(bridgeCatalog) {
t.Fatalf("rows=%d want %d", len(rows), len(nativeChains)+len(bridgeCatalog))
}
if live != 3 {
t.Fatalf("live=%d want 3", live)
}
// Natives lead and are txns-sorted: lux(20618) before zoo(12323) before pars(58).
if rows[0].Slug != "lux" || rows[1].Slug != "zoo" {
t.Fatalf("native order wrong: %s,%s", rows[0].Slug, rows[1].Slug)
}
for _, r := range rows {
if r.Live {
continue
}
// Not live → every metric MUST be nil (never a fabricated zero).
if r.BlockHeight != nil || r.Txns != nil || r.TPS != nil || r.TvlUSD != nil || r.Addresses != nil {
t.Fatalf("%s not live but carries a metric: %+v", r.Slug, r)
}
}
// Every bridge (non-native) row is identity-only + bridge-badged + not live.
for _, r := range rows {
if r.Native {
continue
}
if !r.Bridge || r.Live || r.Txns != nil {
t.Fatalf("bridge row %s not identity-only: %+v", r.Slug, r)
}
}
}
// TestBuildDefiOverview checks aggregation + provenance honesty: totals sum only
// live chains; USD TVL is nil→"unavailable" and present→"explorer-amm"; topChains
// are txns-sorted.
func TestBuildDefiOverview(t *testing.T) {
tvl := 12345.0
m := map[string]chainMetric{
"lux": {Live: true, TotalTxns: 20618, TotalBlocks: 1096462, Addresses: 67, TvlUSD: &tvl},
"zoo": {Live: true, TotalTxns: 12323, TotalBlocks: 13369, Addresses: 53},
}
ov := (&Server{}).buildDefiOverview(m)
if ov.TotalTxns != 32941 {
t.Fatalf("totalTxns=%d want 32941", ov.TotalTxns)
}
if ov.LiveCount != 2 {
t.Fatalf("liveCount=%d want 2", ov.LiveCount)
}
if ov.ChainCount != len(nativeChains)+len(bridgeCatalog) {
t.Fatalf("chainCount=%d", ov.ChainCount)
}
if ov.TotalTvlUSD == nil || *ov.TotalTvlUSD != 12345 || ov.TvlProvenance != "explorer-amm" {
t.Fatalf("tvl aggregation wrong: %v prov=%s", ov.TotalTvlUSD, ov.TvlProvenance)
}
if ov.Volume24hUSD != nil {
t.Fatalf("24h USD volume must be nil (explorer does not expose it)")
}
if len(ov.TopChains) == 0 || ov.TopChains[0].Slug != "lux" {
t.Fatalf("topChains not txns-sorted: %+v", ov.TopChains)
}
// No TVL anywhere → provenance must be honest "unavailable".
ov2 := (&Server{}).buildDefiOverview(map[string]chainMetric{"lux": {Live: true, TotalTxns: 1}})
if ov2.TotalTvlUSD != nil || ov2.TvlProvenance != "unavailable" {
t.Fatalf("empty-TVL provenance wrong: %v %s", ov2.TotalTvlUSD, ov2.TvlProvenance)
}
}
// TestBuildDefiFlows verifies the hub topology + honesty flag: Lux is the hub, one
// arc per non-hub native + one per external counterparty, native weights reflect
// real txns (realFlow), externals are modeled, and the envelope is modeled:true.
func TestBuildDefiFlows(t *testing.T) {
m := map[string]chainMetric{"zoo": {TotalTxns: 12323}, "hanzo": {TotalTxns: 10}}
fl := (&Server{}).buildDefiFlows(m)
if !fl.Modeled || fl.HubSlug != "lux" {
t.Fatalf("flows envelope wrong: modeled=%v hub=%s", fl.Modeled, fl.HubSlug)
}
wantFlows := (len(nativeChains) - 1) + len(bridgeCounterparties)
if len(fl.Flows) != wantFlows {
t.Fatalf("flows=%d want %d", len(fl.Flows), wantFlows)
}
// Sorted by weight desc.
for i := 1; i < len(fl.Flows); i++ {
if fl.Flows[i-1].Weight < fl.Flows[i].Weight {
t.Fatalf("flows not weight-sorted at %d", i)
}
}
// Every arc originates at the hub; native arcs with txns>0 are realFlow.
for _, f := range fl.Flows {
if f.FromSlug != "lux" {
t.Fatalf("flow not from hub: %+v", f)
}
if f.ToSlug == "zoo" && !f.RealFlow {
t.Fatalf("zoo arc has real txns but realFlow=false")
}
}
}
// TestDefiRate covers the sampler: no baseline → nil (honest "unknown"); a seeded
// older baseline → a real, positive rate from the counter delta.
func TestDefiRate(t *testing.T) {
const slug = "test-rate-chain"
defiSampleMu.Lock()
delete(defiSamples, slug)
defiSampleMu.Unlock()
if tps, bt := defiRate(slug, 1000, 500); tps != nil || bt != nil {
t.Fatalf("first sample must be nil,nil; got %v,%v", tps, bt)
}
// Seed a baseline 10s in the past, then a delta of +50 txns / +5 blocks.
defiSampleMu.Lock()
defiSamples[slug] = defiSample{txns: 1000, blocks: 500, at: time.Now().Add(-10 * time.Second)}
defiSampleMu.Unlock()
tps, bt := defiRate(slug, 1050, 505)
if tps == nil || *tps <= 0 {
t.Fatalf("expected positive tps, got %v", tps)
}
if bt == nil || *bt <= 0 {
t.Fatalf("expected positive block time, got %v", bt)
}
}
// TestDefiChainsMockExplorer exercises the FULL real fetch path (getJSON stats +
// blocks, postJSON amm TVL) against a deterministic mock explorer, proving the BFF
// surfaces real numbers where present and honest nulls where absent, and never 5xxes.
func TestDefiChainsMockExplorer(t *testing.T) {
explorer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path
w.Header().Set("Content-Type", "application/json")
switch {
case strings.HasSuffix(p, "/stats") && strings.Contains(p, "/cchain/"):
w.Write([]byte(`{"total_blocks":"1096462","total_transactions":"20618","total_addresses":"67","total_gas_used":"143453881629"}`))
case strings.HasSuffix(p, "/blocks") && strings.Contains(p, "/cchain/"):
w.Write([]byte(`{"items":[{"height":1096461}]}`))
case strings.HasSuffix(p, "/graphql") && strings.Contains(p, "/cchain/"):
// cchain returns a real positive TVL → provenance must flip to explorer-amm.
w.Write([]byte(`{"data":{"factories":[{"totalValueLockedUSD":"42000.5"}]}}`))
case strings.HasSuffix(p, "/stats"):
w.Write([]byte(`{"total_blocks":"0","total_transactions":"0"}`)) // other chains: empty
default:
w.Write([]byte(`{}`))
}
}))
t.Cleanup(explorer.Close)
t.Setenv("EXPLORER_BASE", explorer.URL)
s := NewServer()
mux := http.NewServeMux()
s.Mount(mux)
ts := httptest.NewServer(mux)
t.Cleanup(ts.Close)
resp, err := http.Get(ts.URL + "/v1/world/defi/chains")
if err != nil {
t.Fatalf("request: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("want 200 got %d", resp.StatusCode)
}
var out defiChainsResp
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatalf("decode: %v", err)
}
if out.ChainCount != len(nativeChains)+len(bridgeCatalog) {
t.Fatalf("chainCount=%d", out.ChainCount)
}
var lux *defiChainRow
for i := range out.Chains {
if out.Chains[i].Slug == "lux" {
lux = &out.Chains[i]
break
}
}
if lux == nil || !lux.Live {
t.Fatalf("lux row missing or not live: %+v", lux)
}
if lux.BlockHeight == nil || *lux.BlockHeight != 1096461 {
t.Fatalf("lux blockHeight wrong: %v", lux.BlockHeight)
}
if lux.Txns == nil || *lux.Txns != 20618 {
t.Fatalf("lux txns wrong: %v", lux.Txns)
}
if lux.TvlUSD == nil || *lux.TvlUSD != 42000.5 {
t.Fatalf("lux tvl not surfaced from amm: %v", lux.TvlUSD)
}
if out.TvlProvenance != "explorer-amm" {
t.Fatalf("provenance=%s want explorer-amm", out.TvlProvenance)
}
}
// TestDefiRoutesRegistered asserts the four DeFi routes enumerate in Routes().
func TestDefiRoutesRegistered(t *testing.T) {
want := map[string]bool{
"/v1/world/defi/overview": false, "/v1/world/defi/chains": false,
"/v1/world/defi/flows": false, "/v1/world/defi/protocols": false,
}
for _, p := range NewServer().Routes() {
if _, ok := want[p]; ok {
want[p] = true
}
}
for p, seen := range want {
if !seen {
t.Fatalf("route %q not registered", p)
}
}
}
+10
View File
@@ -130,6 +130,16 @@ func (s *Server) mount(mux registrar) {
mux.HandleFunc("/v1/world/cloud/analytics", s.handleCloudAnalytics)
mux.HandleFunc("/v1/world/cloud/llm", s.handleCloudLLM)
// DeFi dashboard (crypto→DeFi variant). PUBLIC, real Lux-ecosystem data: the
// 184-chain bridge-supported universe merged with live per-chain metrics from
// explorer.lux.network (block height, txns, addresses, computed TPS). USD
// figures the explorer hardcodes to null (TVL/prices) stay null — tvlProvenance
// says so; nothing is fabricated. Bridge-flow arcs are modeled (modeled:true).
mux.HandleFunc("/v1/world/defi/overview", s.handleDefiOverview)
mux.HandleFunc("/v1/world/defi/chains", s.handleDefiChains)
mux.HandleFunc("/v1/world/defi/flows", s.handleDefiFlows)
mux.HandleFunc("/v1/world/defi/protocols", s.handleDefiProtocols)
// AI (Hanzo inference)
mux.HandleFunc("/v1/world/groq-summarize", s.handleSummarize)
mux.HandleFunc("/v1/world/openrouter-summarize", s.handleSummarize)
+113
View File
@@ -0,0 +1,113 @@
//go:build ignore
// gen-defi-chains reads a local checkout of github.com/luxfi/assets and emits the
// bridge-supported chain catalog the DeFi variant renders. It is the ONE
// generator for internal/world/defi_chains.json — the world binary embeds that
// JSON (go:embed), so it carries no runtime dependency on the assets repo.
//
// Usage:
//
// go run scripts/gen-defi-chains.go \
// --assets /home/z/work/lux/assets \
// --out internal/world/defi_chains.json
//
// Only identity is emitted (name/symbol/decimals/explorer/logo/type/tags/status):
// these are the bridge-supported asset universe. Live metrics (block height, TPS,
// TVL) are overlaid at request time by the BFF for the chains it can actually
// reach — never faked here. Logos resolve from Lux's own CDN (assets.lux.network),
// mirroring the assets repo layout, so the board stays a Lux surface end to end.
package main
import (
"encoding/json"
"flag"
"log"
"os"
"path/filepath"
"sort"
"strings"
)
const logoBase = "https://assets.lux.network/blockchains/"
type info struct {
Name string `json:"name"`
Symbol string `json:"symbol"`
Decimals int `json:"decimals"`
Explorer string `json:"explorer"`
Website string `json:"website"`
Type string `json:"type"`
Status string `json:"status"`
Tags []string `json:"tags"`
}
type chain struct {
Slug string `json:"slug"`
Name string `json:"name"`
Symbol string `json:"symbol"`
Decimals int `json:"decimals"`
Explorer string `json:"explorer,omitempty"`
Website string `json:"website,omitempty"`
Logo string `json:"logo"`
Type string `json:"type,omitempty"`
Status string `json:"status,omitempty"`
Tags []string `json:"tags,omitempty"`
}
func main() {
assets := flag.String("assets", "", "path to a luxfi/assets checkout")
out := flag.String("out", "internal/world/defi_chains.json", "output JSON path")
flag.Parse()
if *assets == "" {
log.Fatal("--assets is required (path to a luxfi/assets checkout)")
}
root := filepath.Join(*assets, "blockchains")
entries, err := os.ReadDir(root)
if err != nil {
log.Fatalf("read %s: %v", root, err)
}
var chains []chain
for _, e := range entries {
if !e.IsDir() {
continue
}
slug := e.Name()
raw, err := os.ReadFile(filepath.Join(root, slug, "info", "info.json"))
if err != nil {
continue // no info → not a catalog chain
}
var in info
if err := json.Unmarshal(raw, &in); err != nil {
log.Printf("skip %s: bad info.json: %v", slug, err)
continue
}
if in.Name == "" {
in.Name = slug
}
chains = append(chains, chain{
Slug: slug,
Name: in.Name,
Symbol: strings.ToUpper(in.Symbol),
Decimals: in.Decimals,
Explorer: strings.TrimSuffix(in.Explorer, "/"),
Website: strings.TrimSuffix(in.Website, "/"),
Logo: logoBase + slug + "/info/logo.png",
Type: in.Type,
Status: in.Status,
Tags: in.Tags,
})
}
sort.Slice(chains, func(i, j int) bool { return chains[i].Slug < chains[j].Slug })
buf, err := json.MarshalIndent(chains, "", " ")
if err != nil {
log.Fatal(err)
}
buf = append(buf, '\n')
if err := os.WriteFile(*out, buf, 0o644); err != nil {
log.Fatal(err)
}
log.Printf("wrote %d chains → %s", len(chains), *out)
}
+8
View File
@@ -108,6 +108,7 @@ import {
CloudAnalyticsPanel,
LlmUsagePanel,
BlockchainPanel,
DefiBoardPanel,
AiComputePanel,
EnsoFlywheelPanel,
} from '@/components';
@@ -2667,6 +2668,13 @@ export class App {
this.panels['chains'] = new BlockchainPanel();
}
// DeFi variant (crypto) — the DefiLlama-shaped board leads the view: the
// bridge-supported chain universe + live Lux-ecosystem metrics (block height,
// txns, TPS) from explorer.lux.network, with honest "—" where USD is absent.
if (SITE_VARIANT === 'crypto') {
this.panels['defi-board'] = new DefiBoardPanel();
}
const liveNewsPanel = new LiveNewsPanel();
this.panels['live-news'] = liveNewsPanel;
+48 -3
View File
@@ -50,6 +50,7 @@ import {
type ByoGpu,
type TrafficArc,
} from '@/services/cloud-map';
import { getDefiFlows, type DefiFlow } from '@/services/defi';
import type { WeatherAlert } from '@/services/weather';
import { escapeHtml } from '@/utils/sanitize';
import { t } from '@/services/i18n';
@@ -391,6 +392,9 @@ export class DeckGLMap {
private chainNetworks: ChainNetwork[] = [];
private byoGpus: ByoGpu[] = [];
private trafficArcsData: TrafficArc[] = [];
// Bridge-flow arcs (crypto→DeFi variant): Lux hub ↔ counterparty chains from
// /v1/world/defi/flows. Positions/weights are modeled (chains aren't geographic).
private bridgeFlowsData: DefiFlow[] = [];
private cloudMapTimers: ReturnType<typeof setInterval>[] = [];
// Country highlight state
@@ -470,7 +474,7 @@ export class DeckGLMap {
private pulseDirty = { news: false, cloud: false };
private rafUpdatePulse: () => void;
private static readonly NEWS_PULSE_LAYER_IDS = ['news-pulse-layer', 'hotspots-pulse', 'protest-clusters-pulse'];
private static readonly CLOUD_PULSE_LAYER_IDS = ['chainNodes', 'trafficArcs'];
private static readonly CLOUD_PULSE_LAYER_IDS = ['chainNodes', 'trafficArcs', 'bridgeFlows'];
// Chain-node dots derive from chainNetworks; cache them by source reference so
// the cloud-pulse breathe (a radiusScale-only change) reuses the same data
@@ -568,6 +572,7 @@ export class DeckGLMap {
pull(getChainNodes, (d) => { this.chainNetworks = d.networks ?? []; }, 30_000);
pull(getTraffic, (d) => { this.trafficArcsData = d.arcs ?? []; }, 15_000);
pull(getByoGpu, (d) => { this.byoGpus = d.gpus ?? []; }, 60_000);
pull(getDefiFlows, (d) => { this.bridgeFlowsData = d.flows ?? []; }, 60_000);
}
private setupDOM(): void {
@@ -1425,6 +1430,9 @@ export class DeckGLMap {
if (mapLayers.trafficArcs && this.trafficArcsData.length > 0) {
layers.push(this.createTrafficArcsLayer());
}
if (mapLayers.bridgeFlows && this.bridgeFlowsData.length > 0) {
layers.push(this.createBridgeFlowsLayer());
}
// News geo-locations (always shown if data exists)
if (this.newsLocations.length > 0) {
@@ -1472,6 +1480,7 @@ export class DeckGLMap {
case 'protest-clusters-pulse': return this.createProtestClustersPulseLayer();
case 'chainNodes': return this.createChainNodesLayer();
case 'trafficArcs': return this.createTrafficArcsLayer();
case 'bridgeFlows': return this.createBridgeFlowsLayer();
default: return null;
}
}
@@ -2912,6 +2921,8 @@ export class DeckGLMap {
}
case 'trafficArcs':
return { html: `<div class="deckgl-tooltip"><strong>${text(obj.label)}</strong><br/>weight ${text(obj.weight)}</div>` };
case 'bridgeFlows':
return { html: `<div class="deckgl-tooltip"><strong>${text(obj.label)}</strong><br/>${obj.realFlow ? 'activity-weighted' : 'bridge route (modeled)'}</div>` };
case 'gulf-investments-layer': {
const inv = obj as GulfInvestment;
const flag = inv.investingCountry === 'SA' ? '🇸🇦' : '🇦🇪';
@@ -3281,6 +3292,7 @@ export class DeckGLMap {
{ key: 'chainNodes', label: 'Chain nodes', icon: '&#9939;' },
{ key: 'byoGpu', label: 'BYO GPUs', icon: '&#128187;' },
{ key: 'trafficArcs', label: 'Traffic', icon: '&#8644;' },
{ key: 'bridgeFlows', label: 'Bridge flows', icon: '&#8652;' },
];
// Apply the user's persisted cosmetic ordering of the toggle rows (drag to
@@ -3944,10 +3956,11 @@ export class DeckGLMap {
// when the tab is hidden, so that case needs no extra handling.
private cloudPulseGateOpen(): boolean {
if (this.renderPaused || this.webglLost || this.prefersReducedMotion()) return false;
const { trafficArcs, chainNodes } = this.state.layers;
const { trafficArcs, chainNodes, bridgeFlows } = this.state.layers;
const arcsLive = trafficArcs && this.trafficArcsData.length > 0;
const nodesLive = chainNodes && this.chainNetworks.length > 0;
return Boolean(arcsLive || nodesLive);
const flowsLive = bridgeFlows && this.bridgeFlowsData.length > 0;
return Boolean(arcsLive || nodesLive || flowsLive);
}
private maybeStartCloudPulse(): void {
@@ -4276,6 +4289,38 @@ export class DeckGLMap {
});
}
// Bridge-flow arcs (crypto→DeFi variant): Lux hub → counterparty chains. A Lux
// cyan travelling pulse (same AnimatedArcLayer + cloud-pulse coef as the traffic
// arcs). Real activity-weighted flows (native counterparties) read brighter than
// the modeled external routes, so the honest distinction is visible on the globe.
private createBridgeFlowsLayer(): AnimatedArcLayer<DefiFlow> {
const maxWeight = Math.max(1, ...this.bridgeFlowsData.map((f) => f.weight || 0));
const alpha = (w: number): number => Math.round(40 + Math.min(1, (w || 0) / maxWeight) * 180);
// Lux accent cyan (#5cf) for real flows; a cooler slate for modeled routes.
const color = (d: DefiFlow, a: number): [number, number, number, number] =>
d.realFlow ? [92, 207, 255, a] : [120, 150, 180, Math.round(a * 0.7)];
return new AnimatedArcLayer<DefiFlow>({
id: 'bridgeFlows',
data: this.bridgeFlowsData,
coef: this.cloudPulseCoef,
greatCircle: this.onGlobe,
getHeight: this.onGlobe ? 0 : 1,
getSourcePosition: (d) => [d.fromLon, d.fromLat],
getTargetPosition: (d) => [d.toLon, d.toLat],
getSourceColor: (d) => color(d, alpha(d.weight)),
getTargetColor: (d) => color(d, Math.round(alpha(d.weight) * 0.55)),
getWidth: (d) => 0.6 + Math.min(1, (d.weight || 0) / maxWeight) * 1.8,
widthMinPixels: 0.5,
widthMaxPixels: 2.4,
pickable: true,
updateTriggers: {
getSourceColor: this.bridgeFlowsData.length,
getTargetColor: this.bridgeFlowsData.length,
getWidth: this.bridgeFlowsData.length,
},
});
}
// Data setters - all use render() for debouncing
public setEarthquakes(earthquakes: Earthquake[]): void {
this.earthquakes = earthquakes;
+277
View File
@@ -0,0 +1,277 @@
import { Panel } from './Panel';
import { escapeHtml } from '@/utils/sanitize';
import { fmtCompact, fmtInt, statTile, shareBar } from '@/utils/cloud-format';
import {
getDefiOverview, getDefiChains, getDefiProtocols,
type DefiOverview, type DefiChainsData, type DefiChainRow, type DefiProtocolsData,
} from '@/services/defi';
type SortKey = 'name' | 'blockHeight' | 'txns' | 'tps' | 'tvlUsd' | 'addresses';
type FilterMode = 'all' | 'live' | 'native';
// DeFi board — the DefiLlama-shaped centerpiece of the crypto→DeFi variant. One
// wide panel: a hero of real aggregates (chains, live, transactions, TPS, TVL), a
// sortable + searchable 200-row chain table (Lux-ecosystem chains with live
// metrics leading, then the full bridge-supported universe), and a top-AMM-pools
// strip. Every USD figure the explorer does not populate renders as "—", never a
// fabricated number (the provenance line says which). Rows use content-visibility
// so 190+ rows stay smooth without a bespoke virtual scroller.
export class DefiBoardPanel extends Panel {
private overview: DefiOverview | null = null;
private chains: DefiChainRow[] = [];
private chainsMeta: DefiChainsData | null = null;
private protocols: DefiProtocolsData | null = null;
private loaded = false;
private built = false;
private query = '';
private filter: FilterMode = 'all';
private sortKey: SortKey = 'txns';
private sortDir: 'asc' | 'desc' = 'desc';
private timer: ReturnType<typeof setInterval> | null = null;
constructor() {
super({
id: 'defi-board',
title: 'DeFi',
showCount: true,
className: 'panel-wide cloud-panel defi-panel',
infoTooltip:
'Live Lux-ecosystem chain metrics from explorer.lux.network (block height, ' +
'transactions, addresses, computed TPS) merged with the bridge-supported chain ' +
'universe. USD TVL/prices show "—" until on-chain USD indexing is populated — ' +
'no figures are fabricated.',
});
void this.fetchData();
this.timer = setInterval(() => void this.fetchData(), 30_000);
}
public destroy(): void {
if (this.timer) { clearInterval(this.timer); this.timer = null; }
super.destroy();
}
private async fetchData(): Promise<void> {
const [ov, ch, pr] = await Promise.all([getDefiOverview(), getDefiChains(), getDefiProtocols()]);
if (ov) this.overview = ov;
if (ch) { this.chains = ch.chains ?? []; this.chainsMeta = ch; }
if (pr) this.protocols = pr;
this.loaded = true;
this.render();
}
// ── view model ───────────────────────────────────────────────────────────
private viewRows(): DefiChainRow[] {
const q = this.query.trim().toLowerCase();
let rows = this.chains;
if (this.filter === 'live') rows = rows.filter((r) => r.live);
else if (this.filter === 'native') rows = rows.filter((r) => r.native);
if (q) rows = rows.filter((r) => r.name.toLowerCase().includes(q) || r.symbol.toLowerCase().includes(q) || r.slug.includes(q));
const dir = this.sortDir === 'asc' ? 1 : -1;
const key = this.sortKey;
return [...rows].sort((a, b) => {
if (key === 'name') return dir * a.name.localeCompare(b.name);
const av = a[key] as number | null;
const bv = b[key] as number | null;
// Nulls always sort last regardless of direction (unknown ≠ smallest).
if (av == null && bv == null) return 0;
if (av == null) return 1;
if (bv == null) return -1;
return dir * (av - bv);
});
}
// ── render ────────────────────────────────────────────────────────────────
private render(): void {
if (!this.overview && this.chains.length === 0) {
if (this.loaded) {
this.built = false;
this.setContent('<div class="defi-empty">DeFi data unavailable — the Lux explorer is unreachable right now.</div>');
} else {
this.showLoading('Loading DeFi board…');
}
return;
}
this.setDataBadge('live');
this.setCount(this.chainsMeta?.chainCount ?? this.chains.length);
if (!this.built) this.buildShell();
this.renderHero();
this.renderTable();
this.renderPools();
}
private buildShell(): void {
this.setContent(`
<div class="defi-board">
<div class="defi-hero" data-defi-hero></div>
<div class="defi-toolbar">
<input type="search" class="defi-search" data-defi-search placeholder="Filter 190+ chains…" aria-label="Filter chains" />
<div class="defi-filters" data-defi-filters>
<button type="button" class="defi-chip" data-filter="all">All</button>
<button type="button" class="defi-chip" data-filter="live">Live</button>
<button type="button" class="defi-chip" data-filter="native">Lux</button>
</div>
</div>
<div class="defi-table" role="table">
<div class="defi-thead" data-defi-head role="row"></div>
<div class="defi-tbody" data-defi-body></div>
</div>
<div class="defi-pools" data-defi-pools></div>
</div>`);
const search = this.content.querySelector<HTMLInputElement>('[data-defi-search]');
search?.addEventListener('input', () => { this.query = search.value; this.renderTable(); });
this.content.querySelector('[data-defi-filters]')?.addEventListener('click', (e) => {
const btn = (e.target as HTMLElement).closest<HTMLElement>('.defi-chip');
if (!btn) return;
this.filter = (btn.dataset.filter as FilterMode) || 'all';
this.renderFilters();
this.renderTable();
});
this.content.querySelector('[data-defi-head]')?.addEventListener('click', (e) => {
const col = (e.target as HTMLElement).closest<HTMLElement>('[data-sort]');
if (!col) return;
const key = col.dataset.sort as SortKey;
if (this.sortKey === key) this.sortDir = this.sortDir === 'asc' ? 'desc' : 'asc';
else { this.sortKey = key; this.sortDir = key === 'name' ? 'asc' : 'desc'; }
this.renderHead();
this.renderTable();
});
this.renderHead();
this.renderFilters();
this.built = true;
}
private renderHero(): void {
const el = this.content.querySelector('[data-defi-hero]');
if (!el) return;
const o = this.overview;
const tiles: string[] = [];
tiles.push(statTile(usd(o?.totalTvlUsd ?? null), 'total TVL'));
tiles.push(statTile(usd(o?.volume24hUsd ?? null), '24h volume'));
tiles.push(statTile(fmtInt(o?.chainCount ?? this.chains.length), 'chains'));
tiles.push(statTile(fmtInt(o?.liveCount ?? 0), 'live chains'));
tiles.push(statTile(num(o?.totalTxns ?? null), 'transactions'));
tiles.push(statTile(o?.aggregateTps == null ? '—' : o.aggregateTps.toFixed(2), 'aggregate TPS'));
const prov = (o?.tvlProvenance ?? this.chainsMeta?.tvlProvenance) === 'explorer-amm'
? 'USD TVL from the Lux AMM subgraph.'
: 'USD TVL/prices show “—” until on-chain USD indexing is populated — nothing is fabricated.';
const src = o?.metricsSource ?? this.chainsMeta?.metricsSource ?? 'explorer.lux.network';
el.innerHTML = `
<div class="cloud-stat-grid cloud-stat-grid-8 defi-stat-grid">${tiles.join('')}</div>
<div class="defi-provenance">Live metrics from ${escapeHtml(hostOf(src))} · ${escapeHtml(prov)}</div>`;
}
private renderFilters(): void {
this.content.querySelectorAll<HTMLElement>('.defi-chip').forEach((c) => {
c.classList.toggle('active', c.dataset.filter === this.filter);
});
}
private renderHead(): void {
const el = this.content.querySelector('[data-defi-head]');
if (!el) return;
const cols: Array<{ key: SortKey; label: string; cls: string }> = [
{ key: 'name', label: 'Chain', cls: 'defi-c-name' },
{ key: 'blockHeight', label: 'Block', cls: 'defi-c-num' },
{ key: 'txns', label: 'Txns', cls: 'defi-c-num' },
{ key: 'tps', label: 'TPS', cls: 'defi-c-num' },
{ key: 'addresses', label: 'Addrs', cls: 'defi-c-num' },
{ key: 'tvlUsd', label: 'TVL', cls: 'defi-c-num' },
];
el.innerHTML = cols.map((c) => {
const arrow = this.sortKey === c.key ? (this.sortDir === 'asc' ? ' ▲' : ' ▼') : '';
const active = this.sortKey === c.key ? ' active' : '';
return `<span class="defi-th ${c.cls}${active}" data-sort="${c.key}">${escapeHtml(c.label)}${arrow}</span>`;
}).join('');
}
private renderTable(): void {
const el = this.content.querySelector('[data-defi-body]');
if (!el) return;
const rows = this.viewRows();
if (rows.length === 0) {
el.innerHTML = '<div class="defi-empty-row">No chains match your filter.</div>';
return;
}
el.innerHTML = rows.map((r) => this.rowHTML(r)).join('');
}
private rowHTML(r: DefiChainRow): string {
const badge = r.native
? '<span class="defi-badge defi-badge-native">L1</span>'
: '<span class="defi-badge defi-badge-bridge">bridge</span>';
const dot = r.live ? '<span class="defi-dot live"></span>' : '<span class="defi-dot"></span>';
return `
<div class="defi-row" role="row">
<span class="defi-c-name">
${dot}${logoChip(r)}
<span class="defi-row-name">${escapeHtml(r.name)}</span>
<span class="defi-row-sym">${escapeHtml(r.symbol)}</span>
${badge}
</span>
<span class="defi-c-num defi-mono">${num(r.blockHeight)}</span>
<span class="defi-c-num defi-mono">${num(r.txns)}</span>
<span class="defi-c-num defi-mono">${r.tps == null ? '—' : r.tps.toFixed(2)}</span>
<span class="defi-c-num defi-mono">${num(r.addresses)}</span>
<span class="defi-c-num defi-mono">${usd(r.tvlUsd)}</span>
</div>`;
}
private renderPools(): void {
const el = this.content.querySelector('[data-defi-pools]');
if (!el) return;
const pools = this.protocols?.pools ?? [];
if (pools.length === 0) {
el.innerHTML = '<div class="defi-subhead">Top AMM pools</div><div class="defi-empty-row">Pool indexing in progress.</div>';
return;
}
const maxTvl = Math.max(1, ...pools.map((p) => p.tvlUsd ?? 0));
const rows = pools.slice(0, 8).map((p) => `
<div class="defi-pool-row">
<span class="defi-pool-pair">${escapeHtml(p.pair)}</span>
<span class="defi-pool-chain">${escapeHtml(p.chain)}</span>
<span class="defi-pool-tvl defi-mono">${usd(p.tvlUsd)}</span>
${p.tvlUsd != null ? shareBar((p.tvlUsd ?? 0) / maxTvl) : ''}
</div>`).join('');
el.innerHTML = `<div class="defi-subhead">Top AMM pools · ${this.protocols?.poolCount ?? pools.length}</div>${rows}`;
}
}
// ── formatting + logo helpers ────────────────────────────────────────────────
/** Compact USD; '—' when unknown (null). Dollars in (not cents). */
function usd(n: number | null): string {
if (n == null) return '—';
return '$' + fmtCompact(n);
}
/** Compact integer; '—' when unknown (null). */
function num(n: number | null): string {
if (n == null) return '—';
return fmtCompact(n);
}
function hostOf(url: string): string {
try { return new URL(url).host || url; } catch { return url; }
}
/**
* A logo chip, CSP-safe (no inline JS): bridge chains carry a reliable Lux-CDN
* logo painted as a cover background; our own chains have no CDN entry, so they
* render a cyan symbol initial-chip. A rare 404 degrades to an empty dark circle,
* never a broken-image icon.
*/
function logoChip(r: DefiChainRow): string {
if (r.logo) {
return `<span class="defi-logo" style="background-image:url('${encodeURI(r.logo)}')"></span>`;
}
const initial = escapeHtml((r.symbol || r.name || '?').slice(0, 1).toUpperCase());
return `<span class="defi-logo defi-logo-initial">${initial}</span>`;
}
+1
View File
@@ -69,3 +69,4 @@ export { HanzoStatusPanel } from './HanzoStatusPanel';
export { CloudAnalyticsPanel } from './CloudAnalyticsPanel';
export { LlmUsagePanel } from './LlmUsagePanel';
export { BlockchainPanel } from './BlockchainPanel';
export { DefiBoardPanel } from './DefiBoardPanel';
+26 -27
View File
@@ -568,35 +568,32 @@ const AI_MOBILE_MAP_LAYERS: MapLayers = {
};
// ============================================
// CRYPTO VARIANT (Digital assets & markets)
// CRYPTO → DeFi VARIANT (Lux DeFi dashboard)
// ============================================
// Composition of existing panels: CoinGecko prices + crypto news up top, the
// trader desk (BTC dominance / funding), ETF flows, stablecoins, market radar,
// sector heatmap, sentiment and predictions. Feeds resolve to FINANCE_FEEDS (see
// feeds.ts) — the `crypto`/`markets`/`economic` data panels each also spawn a
// `-news` feed panel via the FEEDS loop in App.ts.
// The DefiLlama-shaped board leads: the bridge-supported chain universe (bridge.
// lux.network) merged with live Lux-ecosystem metrics from explorer.lux.network,
// plus bridge-flow arcs on the globe. Crypto/markets panels remain available but
// off by default (enabled:false) so the view is a DeFi dashboard, not a trad-
// markets desk — they stay one toggle away in the Panels menu.
const CRYPTO_PANELS: Record<string, PanelConfig> = {
map: { name: 'Global Crypto Map', enabled: true, priority: 1 },
'live-news': { name: 'Crypto & Markets Headlines', enabled: true, priority: 1 },
insights: { name: 'AI Market Insights', enabled: true, priority: 1 },
crypto: { name: 'Crypto & Digital Assets', enabled: true, priority: 1 },
'defi-board': { name: 'DeFi', enabled: true, priority: 1 },
map: { name: 'Bridge Flows Map', enabled: true, priority: 1 },
chains: { name: 'Chains', enabled: true, priority: 1 },
'crypto-news': { name: 'Crypto News', enabled: true, priority: 1 },
'trader-desk': { name: 'Trader Desk', enabled: true, priority: 1 },
'etf-flows': { name: 'BTC ETF Tracker', enabled: true, priority: 1 },
stablecoins: { name: 'Stablecoins', enabled: true, priority: 1 },
'macro-signals': { name: 'Market Radar', enabled: true, priority: 1 },
heatmap: { name: 'Sector Heatmap', enabled: true, priority: 1 },
polymarket: { name: 'Predictions', enabled: true, priority: 1 },
sentiment: { name: 'News Sentiment', enabled: true, priority: 1 },
markets: { name: 'Live Markets', enabled: true, priority: 2 },
fx: { name: 'FX & dollar', enabled: true, priority: 2 },
yields: { name: 'Rates & credit', enabled: true, priority: 2 },
'economic-news': { name: 'Economic News', enabled: true, priority: 2 },
regulation: { name: 'Crypto & Financial Regulation', enabled: true, priority: 2 },
centralbanks: { name: 'Central Bank Watch', enabled: true, priority: 2 },
'live-news': { name: 'Crypto & DeFi Headlines', enabled: true, priority: 1 },
'crypto-news': { name: 'Crypto News', enabled: true, priority: 2 },
insights: { name: 'AI Market Insights', enabled: true, priority: 2 },
'ai-analyst': { name: 'AI analyst', enabled: true, priority: 2 },
monitors: { name: 'My Monitors', enabled: true, priority: 2 },
// Available but off by default in the DeFi view.
crypto: { name: 'Crypto & Digital Assets', enabled: false, priority: 2 },
'trader-desk': { name: 'Trader Desk', enabled: false, priority: 2 },
'etf-flows': { name: 'BTC ETF Tracker', enabled: false, priority: 2 },
stablecoins: { name: 'Stablecoins', enabled: false, priority: 2 },
'macro-signals': { name: 'Market Radar', enabled: false, priority: 2 },
heatmap: { name: 'Sector Heatmap', enabled: false, priority: 2 },
polymarket: { name: 'Predictions', enabled: false, priority: 2 },
sentiment: { name: 'News Sentiment', enabled: false, priority: 2 },
markets: { name: 'Live Markets', enabled: false, priority: 2 },
};
const CRYPTO_MAP_LAYERS: MapLayers = {
@@ -639,10 +636,12 @@ const CRYPTO_MAP_LAYERS: MapLayers = {
centralBanks: false,
commodityHubs: false,
gulfInvestments: false,
// Hanzo World cloud map layers
// Hanzo World cloud map layers. The DeFi view's arcs are BRIDGE FLOWS (Lux hub
// ↔ counterparties), not request traffic — so trafficArcs off, bridgeFlows on.
chainNodes: true,
byoGpu: true,
trafficArcs: true,
byoGpu: false,
trafficArcs: false,
bridgeFlows: true,
};
const CRYPTO_MOBILE_MAP_LAYERS: MapLayers = {
+12 -18
View File
@@ -17,23 +17,12 @@ import type { VariantConfig } from './base';
export * from './base';
export const DEFAULT_PANELS: Record<string, PanelConfig> = {
watch: { name: 'Watch Queue', enabled: true, priority: 2 },
map: { name: 'Global Crypto Map', enabled: true, priority: 1 },
'live-news': { name: 'Crypto & Markets Headlines', enabled: true, priority: 1 },
insights: { name: 'AI Market Insights', enabled: true, priority: 1 },
crypto: { name: 'Crypto & Digital Assets', enabled: true, priority: 1 },
'crypto-news': { name: 'Crypto News', enabled: true, priority: 1 },
'trader-desk': { name: 'Trader Desk', enabled: true, priority: 1 },
'etf-flows': { name: 'BTC ETF Tracker', enabled: true, priority: 1 },
stablecoins: { name: 'Stablecoins', enabled: true, priority: 1 },
'macro-signals': { name: 'Market Radar', enabled: true, priority: 1 },
heatmap: { name: 'Sector Heatmap', enabled: true, priority: 1 },
polymarket: { name: 'Predictions', enabled: true, priority: 1 },
sentiment: { name: 'News Sentiment', enabled: true, priority: 1 },
markets: { name: 'Live Markets', enabled: true, priority: 2 },
'economic-news': { name: 'Economic News', enabled: true, priority: 2 },
regulation: { name: 'Crypto & Financial Regulation', enabled: true, priority: 2 },
centralbanks: { name: 'Central Bank Watch', enabled: true, priority: 2 },
'defi-board': { name: 'DeFi', enabled: true, priority: 1 },
map: { name: 'Bridge Flows Map', enabled: true, priority: 1 },
chains: { name: 'Chains', enabled: true, priority: 1 },
'live-news': { name: 'Crypto & DeFi Headlines', enabled: true, priority: 1 },
'crypto-news': { name: 'Crypto News', enabled: true, priority: 2 },
insights: { name: 'AI Market Insights', enabled: true, priority: 2 },
'ai-analyst': { name: 'AI analyst', enabled: true, priority: 2 },
monitors: { name: 'My Monitors', enabled: true, priority: 2 },
};
@@ -77,6 +66,11 @@ export const DEFAULT_MAP_LAYERS: MapLayers = {
centralBanks: false,
commodityHubs: false,
gulfInvestments: false,
// Hanzo World cloud map layers — the DeFi view's arcs are BRIDGE FLOWS.
chainNodes: true,
byoGpu: false,
trafficArcs: false,
bridgeFlows: true,
};
export const MOBILE_DEFAULT_MAP_LAYERS: MapLayers = {
@@ -87,7 +81,7 @@ export const MOBILE_DEFAULT_MAP_LAYERS: MapLayers = {
export const VARIANT_CONFIG: VariantConfig = {
name: 'crypto',
description: 'Crypto, digital assets & markets intelligence dashboard',
description: 'Lux DeFi dashboard — bridge-supported chain universe + live on-chain metrics',
panels: DEFAULT_PANELS,
mapLayers: DEFAULT_MAP_LAYERS,
mobileMapLayers: MOBILE_DEFAULT_MAP_LAYERS,
+126
View File
@@ -0,0 +1,126 @@
// DeFi dashboard data — same-origin /v1/world/defi/* feeds behind the crypto→DeFi
// variant. Sovereign Lux data: the bridge-supported chain universe merged with
// live per-chain metrics from explorer.lux.network. Every fetch is best-effort and
// resolves to null on any HTTP/network/parse error so a panel keeps its last good
// state instead of throwing. USD figures the explorer does not populate arrive as
// null — the UI shows "—", never a fabricated number (tvlProvenance says which).
export interface DefiChainRow {
slug: string;
name: string;
symbol: string;
logo: string; // '' for our own chains → the UI renders a symbol initial-chip
explorer?: string;
chainId?: number;
native: boolean; // one of our sovereign L1s (Lux/Zoo/Hanzo/…)
bridge: boolean; // bridge-supported
live: boolean;
blockHeight: number | null;
txns: number | null;
addresses: number | null;
tps: number | null;
blockTime: number | null;
tvlUsd: number | null;
status?: string;
tags?: string[];
}
export interface DefiChainsData {
updatedAt: string;
chainCount: number;
nativeCount: number;
bridgeCount: number;
liveCount: number;
metricsSource: string;
tvlProvenance: string; // 'explorer-amm' | 'unavailable'
chains: DefiChainRow[];
}
export interface DefiTopChain {
slug: string;
name: string;
symbol: string;
txns: number;
tps: number | null;
tvlUsd: number | null;
live: boolean;
}
export interface DefiOverview {
updatedAt: string;
chainCount: number;
nativeCount: number;
bridgeCount: number;
liveCount: number;
totalTxns: number;
totalBlocks: number;
totalAddresses: number;
aggregateTps: number | null;
totalTvlUsd: number | null;
volume24hUsd: number | null;
metricsSource: string;
tvlProvenance: string;
topChains: DefiTopChain[];
}
export interface DefiFlow {
fromSlug: string;
toSlug: string;
fromLat: number;
fromLon: number;
toLat: number;
toLon: number;
weight: number;
label: string;
realFlow: boolean;
}
export interface DefiFlowsData {
updatedAt: string;
modeled: boolean;
hubSlug: string;
flows: DefiFlow[];
}
export interface DefiPool {
chain: string;
pair: string;
token0: string;
token1: string;
tvlUsd: number | null;
volUsd: number | null;
}
export interface DefiProtocolsData {
updatedAt: string;
metricsSource: string;
poolCount: number;
pools: DefiPool[];
}
/** GET a same-origin JSON path. Resolves to null on any HTTP/network/parse error. */
async function getJson<T>(path: string): Promise<T | null> {
try {
const res = await fetch(path);
if (!res.ok) return null;
return (await res.json()) as T;
} catch {
return null;
}
}
export function getDefiOverview(): Promise<DefiOverview | null> {
return getJson<DefiOverview>('/v1/world/defi/overview');
}
export function getDefiChains(): Promise<DefiChainsData | null> {
return getJson<DefiChainsData>('/v1/world/defi/chains');
}
export function getDefiFlows(): Promise<DefiFlowsData | null> {
return getJson<DefiFlowsData>('/v1/world/defi/flows');
}
export function getDefiProtocols(): Promise<DefiProtocolsData | null> {
return getJson<DefiProtocolsData>('/v1/world/defi/protocols');
}
+57
View File
@@ -17190,3 +17190,60 @@ body.layout-free .panels-grid::after {
.wq-row-src { color: var(--text-dim); font-size: 9px; flex-shrink: 0; }
.wq-row-x { background: none; border: none; color: var(--text-dim); cursor: pointer; padding: 0 3px; font-size: 12px; }
.wq-row-x:hover { color: var(--red, #f55); }
/* DeFi board (cryptoDeFi variant)
* The DefiLlama-shaped board: hero aggregates + a sortable/searchable 190-row
* chain table + top-AMM-pools. Monochrome to match the app; a single Lux-cyan
* (#5cf) accent ties live/native chips to the globe's bridge-flow arcs. Rows use
* content-visibility so the full universe scrolls smoothly. */
.defi-panel .panel-content { padding: 0; }
.defi-board { display: flex; flex-direction: column; height: 100%; min-height: 0; gap: 10px; padding: 10px 12px 12px; }
.defi-hero { flex: 0 0 auto; }
.defi-stat-grid { margin-bottom: 6px; }
.defi-provenance { font-size: 11px; color: #7a7a7a; line-height: 1.4; }
.defi-toolbar { flex: 0 0 auto; display: flex; align-items: center; gap: 8px; }
.defi-search { flex: 1; min-width: 0; background: #0e0e0e; border: 1px solid #262626; border-radius: 6px; color: #ededed; font-size: 12px; padding: 6px 10px; outline: none; }
.defi-search:focus { border-color: #3a3a3a; }
.defi-filters { display: flex; gap: 4px; }
.defi-chip { background: transparent; border: 1px solid #262626; border-radius: 999px; color: #999; font-size: 11px; padding: 4px 10px; cursor: pointer; }
.defi-chip:hover { color: #ededed; border-color: #3a3a3a; }
.defi-chip.active { background: #ededed; color: #0a0a0a; border-color: #ededed; }
.defi-table { flex: 1 1 auto; min-height: 120px; display: flex; flex-direction: column; border: 1px solid #1a1a1a; border-radius: 8px; overflow: hidden; }
.defi-thead, .defi-row { display: grid; grid-template-columns: minmax(170px, 2.4fr) repeat(5, minmax(52px, 0.8fr)); align-items: center; gap: 6px; }
.defi-thead { flex: 0 0 auto; padding: 8px 12px; border-bottom: 1px solid #1f1f1f; background: #0d0d0d; }
.defi-th { font-size: 10px; letter-spacing: 0.06em; text-transform: uppercase; color: #7a7a7a; cursor: pointer; user-select: none; white-space: nowrap; }
.defi-th:hover, .defi-th.active { color: #ededed; }
.defi-th.defi-c-num { text-align: right; }
.defi-tbody { flex: 1 1 auto; min-height: 0; overflow-y: auto; overflow-x: hidden; }
.defi-row { padding: 7px 12px; border-bottom: 1px solid #141414; content-visibility: auto; contain-intrinsic-size: 0 38px; }
.defi-row:hover { background: #0f0f0f; }
.defi-c-name { display: flex; align-items: center; gap: 7px; min-width: 0; }
.defi-c-num { text-align: right; color: #cfcfcf; font-size: 12px; }
.defi-mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.defi-row-name { color: #ededed; font-size: 13px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.defi-row-sym { color: #777; font-size: 11px; font-family: var(--font-mono); flex: 0 0 auto; }
.defi-dot { width: 6px; height: 6px; border-radius: 50%; background: #333; flex: 0 0 auto; }
.defi-dot.live { background: #5cf; box-shadow: 0 0 6px rgba(92, 207, 255, 0.7); }
.defi-logo { width: 20px; height: 20px; border-radius: 50%; flex: 0 0 auto; background-color: #161616; background-size: cover; background-position: center; display: inline-flex; align-items: center; justify-content: center; font-size: 9px; font-weight: 700; font-family: var(--font-mono); overflow: hidden; }
.defi-logo-initial { color: #5cf; background-color: rgba(92, 207, 255, 0.10); }
.defi-badge { font-size: 9px; letter-spacing: 0.04em; text-transform: uppercase; padding: 1px 5px; border-radius: 4px; flex: 0 0 auto; }
.defi-badge-native { background: rgba(92, 207, 255, 0.14); color: #5cf; }
.defi-badge-bridge { background: #171717; color: #777; }
.defi-pools { flex: 0 0 auto; }
.defi-subhead { font-size: 11px; letter-spacing: 0.05em; text-transform: uppercase; color: #7a7a7a; margin: 4px 0 6px; }
.defi-pool-row { display: grid; grid-template-columns: 1.4fr 1fr auto; align-items: center; gap: 8px; padding: 4px 0; font-size: 12px; }
.defi-pool-pair { color: #ededed; font-family: var(--font-mono); }
.defi-pool-chain { color: #888; }
.defi-pool-tvl { text-align: right; color: #cfcfcf; }
.defi-empty, .defi-empty-row { color: #777; font-size: 12px; padding: 16px 12px; text-align: center; }
+2
View File
@@ -563,6 +563,8 @@ export interface MapLayers {
chainNodes?: boolean;
byoGpu?: boolean;
trafficArcs?: boolean;
// Bridge-flow arcs (crypto→DeFi variant): Lux hub ↔ bridge counterparties.
bridgeFlows?: boolean;
}
export interface AIDataCenter {