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:
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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: '⛓' },
|
||||
{ key: 'byoGpu', label: 'BYO GPUs', icon: '💻' },
|
||||
{ key: 'trafficArcs', label: 'Traffic', icon: '⇄' },
|
||||
{ key: 'bridgeFlows', label: 'Bridge flows', icon: '⇌' },
|
||||
];
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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 (crypto→DeFi 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; }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user