mirror of
https://github.com/luxfi/explore.git
synced 2026-09-24 00:28:18 +00:00
feat(explore): fee burn + staking rewards panel, from one balance
creditTxFee credits floor(fee/2) to the fee reward vault 0x0100..0002 and leaves
the rest credited to no account at all. The vault has no code and no key, so its
balance only grows, and it is BOTH counters at once: rewards accrued exactly, and
supply burned to within one wei per transaction. One eth_getBalance is the whole
data set — no indexer, no event log, no receipts. That is the panel.
Reads are one batched JSON-RPC round trip (vault balance, coinbase balance, head
block, eth_getChainConfig), polled every 30s.
Decimals: the divisor is 10^18, NOT 10^9. nLUX is the P-Chain staking unit; this
is a C-Chain EVM balance from eth_getBalance, i.e. wei, and it goes through the
one canonical path (NativeCoinValue -> config.chain.currency.decimals). Pinned by
a test against live mainnet: the coinbase holds 3867543864740765677447 wei =
3,867.54 LUX. At 10^9 it would print 3.87 trillion LUX, about double the supply.
Same class of bug as 1b6e86a16, one billion times larger.
Honest about the zero. Mainnet's config has no feeSplitTimestamp, so the split has
never fired and nothing is deflationary. The panel says exactly that, tags itself
"Not active", and shows where the fees really go: the coinbase 0x0100..0000, which
has no code and no key either, so its 3,867 LUX is stranded, not burned.
Two traps the tests pin:
- The gate reads the HEAD BLOCK's timestamp, mirroring IsFeeSplit(blockTime).
Devnet's feeSplitTimestamp has passed in wall-clock terms while its C-Chain
sits at height 0 — wall clock would report a burn that never happened.
- Burn is zero before activation, never the raw vault balance. The vault is a
plain account; anyone can transfer into it, and calling that "burned" invents
the deflation this panel exists to report honestly. Unverifiable state prints
a dash, never a zero.
It lives on the home overview, not /stats: guards.stats 404s the whole /stats
route when the stats microservice is not configured, and burn is a chain fact that
owes nothing to an indexer. Not gated on the primary network either — it reads
whichever chain it is pointed at and reports that chain's own state.
Chart: the vault balance sampled once per new block, deduplicated on block number
and capped at 240 points. There is no cheaper honest source — the nodes prune, so
eth_getBalance one block back already answers "missing trie node", and
api-explore.lux.network serves 404 for every path including /.
next.config.js: `next dev` runs Turbopack, which never read the webpack() block, so
every `import Icon from 'icons/*.svg'` resolved to a static-image object and React
threw "Element type is invalid ... but got: object" — Hint, ChartWidget,
toolkit/next/link and BackToButton all import SVGs directly, so the dev server had
been rendering an error page instead of the app. Same loader, declared for both
bundlers.
Verified against live mainnet 96369 in a real browser, not by HTTP status:
panel renders "Not active", "LUX burned 0", "Staking rewards accrued 0",
"Held by the fee coinbase 3,867.5496 LUX", "read at block #1,098,193";
exactly one batched RPC call carrying all four methods; 0px mobile overflow.
The active and scheduled branches, which mainnet cannot exercise, were driven with
a stubbed RPC: "Active" copy, both counters at the vault balance, coinbase card
correctly hidden, and the chart drawing after three samples.
17 unit tests, 288 in the suite, all green. Typecheck unchanged at 223
pre-existing errors, none in these files.
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
// C-Chain (EVM) API hooks and types.
|
||||
|
||||
export {
|
||||
useFeeSplit,
|
||||
burnedWei,
|
||||
deriveStatus,
|
||||
parseFeeSplitBatch,
|
||||
recordSample,
|
||||
toCoinSeries,
|
||||
FEE_REWARD_VAULT,
|
||||
FEE_COINBASE,
|
||||
} from './useFeeSplit';
|
||||
|
||||
export type {
|
||||
FeeSplitStatus,
|
||||
FeeSplitChainConfig,
|
||||
FeeSplitReading,
|
||||
FeeSplitSample,
|
||||
FeeSplitData,
|
||||
RpcEnvelope,
|
||||
} from './useFeeSplit';
|
||||
@@ -0,0 +1,185 @@
|
||||
// Wire-contract and arithmetic guard for the fee-split panel.
|
||||
//
|
||||
// Two things can go wrong here and both are silent:
|
||||
//
|
||||
// 1. The decimals. A C-Chain balance from eth_getBalance is WEI (10^18).
|
||||
// nLUX (10^9) is the P-Chain staking denomination, and a 10^9 divisor
|
||||
// already shipped once in this repo (commit 1b6e86a16) — every LUX figure
|
||||
// was 1000x too large. On a burn counter the same slip would be 10^9x.
|
||||
//
|
||||
// 2. The activation gate. Reporting "burned" on a chain where the split has
|
||||
// never fired invents deflation that is not happening.
|
||||
//
|
||||
// The batch below is a verbatim response from the live mainnet C-Chain
|
||||
// (https://api.lux.network/v1/bc/C/rpc, chainId 96369, head 1098191).
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { FeeSplitReading, RpcEnvelope } from './useFeeSplit';
|
||||
import { burnedWei, deriveStatus, parseFeeSplitBatch, recordSample, toCoinSeries } from './useFeeSplit';
|
||||
|
||||
// Verbatim, trimmed only of chain-config fields the panel never reads.
|
||||
// id 1 = fee reward vault balance, id 2 = coinbase balance,
|
||||
// id 3 = head block, id 4 = eth_getChainConfig.
|
||||
const LIVE_MAINNET_BATCH: Array<RpcEnvelope> = [
|
||||
{ id: 1, result: '0x0' },
|
||||
{ id: 2, result: '0xd1a8f3fddb2abeb787' },
|
||||
{ id: 3, result: { number: '0x10c1cf', timestamp: '0x6a6388cb' } },
|
||||
{ id: 4, result: { chainId: 96369, feeConfig: {}, upgrades: {} } },
|
||||
];
|
||||
|
||||
// What a node that does not implement the luxfi/evm extension answers.
|
||||
const NO_CHAIN_CONFIG_BATCH: Array<RpcEnvelope> = [
|
||||
{ id: 1, result: '0x0' },
|
||||
{ id: 2, result: '0x0' },
|
||||
{ id: 3, result: { number: '0x10c1cf', timestamp: '0x6a6388cb' } },
|
||||
{ id: 4, error: { message: 'the method eth_getChainConfig does not exist/is not available' } },
|
||||
];
|
||||
|
||||
const READING = (over: Partial<FeeSplitReading> = {}): FeeSplitReading => ({
|
||||
status: 'active',
|
||||
vaultWei: '0',
|
||||
coinbaseWei: '0',
|
||||
blockNumber: 1,
|
||||
blockTime: 1_785_000_000,
|
||||
activatesAt: null,
|
||||
allowFeeRecipients: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('deriveStatus — mirrors extras.ChainConfig.IsFeeSplit', () => {
|
||||
it('reports unknown when the node does not serve a chain config', () => {
|
||||
expect(deriveStatus(null, 1_785_000_000)).toBe('unknown');
|
||||
});
|
||||
|
||||
it('reports inactive when feeSplitTimestamp is absent (nil = never)', () => {
|
||||
// This is live mainnet today: the config is served, the field is not set.
|
||||
expect(deriveStatus({}, 1_785_000_000)).toBe('inactive');
|
||||
});
|
||||
|
||||
it('activates at the timestamp, not after it', () => {
|
||||
expect(deriveStatus({ feeSplitTimestamp: 1000 }, 999)).toBe('scheduled');
|
||||
expect(deriveStatus({ feeSplitTimestamp: 1000 }, 1000)).toBe('active');
|
||||
expect(deriveStatus({ feeSplitTimestamp: 1000 }, 1001)).toBe('active');
|
||||
});
|
||||
|
||||
it('reads BLOCK time, not wall clock: a chain stuck at genesis has split nothing', () => {
|
||||
// Devnet 96367: feeSplitTimestamp 1785133547 is set and has passed in
|
||||
// wall-clock terms, but the C-Chain has never built a block, so no fee has
|
||||
// ever been split. Wall clock would say "active" and report a burn that
|
||||
// did not happen.
|
||||
expect(deriveStatus({ feeSplitTimestamp: 1_785_133_547 }, 0)).toBe('scheduled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseFeeSplitBatch — live mainnet wire contract', () => {
|
||||
it('reads the vault, the coinbase and the head from one batch', () => {
|
||||
const reading = parseFeeSplitBatch(LIVE_MAINNET_BATCH);
|
||||
|
||||
expect(reading.vaultWei).toBe('0');
|
||||
expect(reading.coinbaseWei).toBe('3867543864740765677447');
|
||||
expect(reading.blockNumber).toBe(1098191);
|
||||
expect(reading.blockTime).toBe(1784907979);
|
||||
});
|
||||
|
||||
it('mainnet today is inactive: the split has never fired, nothing is burned', () => {
|
||||
const reading = parseFeeSplitBatch(LIVE_MAINNET_BATCH);
|
||||
|
||||
expect(reading.status).toBe('inactive');
|
||||
expect(reading.activatesAt).toBeNull();
|
||||
expect(reading.vaultWei).toBe('0');
|
||||
});
|
||||
|
||||
it('falls back to unknown — never to inactive — when the config call errors', () => {
|
||||
// An errored config must not be read as "no feeSplitTimestamp"; the panel
|
||||
// has to say it cannot tell rather than assert a state it did not observe.
|
||||
expect(parseFeeSplitBatch(NO_CHAIN_CONFIG_BATCH).status).toBe('unknown');
|
||||
});
|
||||
|
||||
it('throws rather than reporting zero when a balance call errors', () => {
|
||||
const failed: Array<RpcEnvelope> = [
|
||||
{ id: 1, error: { message: 'missing trie node' } },
|
||||
...LIVE_MAINNET_BATCH.slice(1),
|
||||
];
|
||||
expect(() => parseFeeSplitBatch(failed)).toThrow(/fee vault balance/);
|
||||
});
|
||||
|
||||
it('keeps full wei precision (a balance overflows Number)', () => {
|
||||
const big: Array<RpcEnvelope> = [
|
||||
{ id: 1, result: '0xd1a8f3fddb2abeb787' },
|
||||
...LIVE_MAINNET_BATCH.slice(1),
|
||||
];
|
||||
// Number() would round this to ...677440.
|
||||
expect(parseFeeSplitBatch(big).vaultWei).toBe('3867543864740765677447');
|
||||
});
|
||||
});
|
||||
|
||||
describe('burnedWei — a balance is not a burn', () => {
|
||||
it('is the vault balance once the split is active', () => {
|
||||
expect(burnedWei(READING({ status: 'active', vaultWei: '900' }))).toBe('900');
|
||||
});
|
||||
|
||||
it('is exactly zero before activation, even if the vault holds a balance', () => {
|
||||
// The vault is a plain account: anyone can transfer into it. Before the
|
||||
// split fires, creditTxFee has destroyed nothing, so that balance is not a
|
||||
// burn and must never be rendered as one.
|
||||
expect(burnedWei(READING({ status: 'inactive', vaultWei: '7500000000000000000' }))).toBe('0');
|
||||
expect(burnedWei(READING({ status: 'scheduled', vaultWei: '7500000000000000000' }))).toBe('0');
|
||||
});
|
||||
|
||||
it('is undefined — not zero — when the activation state could not be read', () => {
|
||||
expect(burnedWei(READING({ status: 'unknown', vaultWei: '900' }))).toBeUndefined();
|
||||
expect(burnedWei(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('toCoinSeries — the divisor is 10^18, not 10^9', () => {
|
||||
it('renders the live coinbase pile as 3,867 LUX, not 3.87 trillion', () => {
|
||||
const [ point ] = toCoinSeries([
|
||||
{ blockNumber: 1098191, blockTime: 1784907979, vaultWei: '3867543864740765677447' },
|
||||
]);
|
||||
|
||||
expect(point.value).toBeCloseTo(3867.54386474, 6);
|
||||
// The 10^9 bug would land at 3.867e12 — above the whole LUX supply.
|
||||
expect(point.value).toBeLessThan(1e6);
|
||||
expect(point.date.getTime()).toBe(1784907979 * 1000);
|
||||
});
|
||||
|
||||
it('does not round a single wei up to a whole coin', () => {
|
||||
const [ point ] = toCoinSeries([ { blockNumber: 1, blockTime: 1, vaultWei: '1' } ]);
|
||||
expect(point.value).toBe(1e-18);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordSample', () => {
|
||||
it('records one observation per block and ignores repeats of the same block', () => {
|
||||
const buffer: Array<{ blockNumber: number; blockTime: number; vaultWei: string }> = [];
|
||||
|
||||
recordSample(READING({ blockNumber: 10, vaultWei: '100' }), buffer);
|
||||
recordSample(READING({ blockNumber: 10, vaultWei: '100' }), buffer);
|
||||
const series = recordSample(READING({ blockNumber: 11, vaultWei: '150' }), buffer);
|
||||
|
||||
expect(series.map((s) => s.blockNumber)).toEqual([ 10, 11 ]);
|
||||
expect(series.map((s) => s.vaultWei)).toEqual([ '100', '150' ]);
|
||||
});
|
||||
|
||||
it('returns a fresh array so the chart re-renders, and never the buffer itself', () => {
|
||||
const buffer: Array<{ blockNumber: number; blockTime: number; vaultWei: string }> = [];
|
||||
const first = recordSample(READING({ blockNumber: 1 }), buffer);
|
||||
const second = recordSample(READING({ blockNumber: 2 }), buffer);
|
||||
|
||||
expect(first).not.toBe(buffer);
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
|
||||
it('caps the buffer so an all-day tab cannot grow without bound', () => {
|
||||
const buffer: Array<{ blockNumber: number; blockTime: number; vaultWei: string }> = [];
|
||||
let series: ReadonlyArray<{ blockNumber: number }> = [];
|
||||
for (let i = 0; i < 300; i++) {
|
||||
series = recordSample(READING({ blockNumber: i }), buffer);
|
||||
}
|
||||
|
||||
expect(series).toHaveLength(240);
|
||||
expect(series[series.length - 1].blockNumber).toBe(299);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
// The transaction-fee 50/50 split, read straight from the chain — one balance.
|
||||
//
|
||||
// luxfi/evm core/fee_split.go `creditTxFee`, while the split is active, credits
|
||||
// rewardShare = floor(fee/2) to extras.FeeRewardVault and leaves
|
||||
// burnShare = fee - rewardShare credited to NO account. Uncredited means the wei
|
||||
// is in no account at all, so the sum of all balances — the supply — falls. That
|
||||
// is a true burn, mechanically identical to the EIP-1559 base-fee burn, not a
|
||||
// transfer to a dead address (which would leave supply unchanged).
|
||||
//
|
||||
// The vault has no code and no key, so its balance only ever grows, and it is
|
||||
// therefore BOTH counters at once:
|
||||
//
|
||||
// rewards accrued = vault balance (exact)
|
||||
// supply burned = vault balance + one wei per odd fee (>= vault balance)
|
||||
//
|
||||
// One eth_getBalance answers both. No indexer, no event log, no receipts.
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import BigNumber from 'bignumber.js';
|
||||
|
||||
import config from 'configs/app';
|
||||
|
||||
// extras.FeeRewardVault — the protocol-owned account creditTxFee pays
|
||||
// rewardShare into, to be exported to the P-Chain staking-reward pool.
|
||||
export const FEE_REWARD_VAULT = '0x0100000000000000000000000000000000000002';
|
||||
|
||||
// The C-Chain block coinbase: the blackhole that receives the WHOLE fee while
|
||||
// the split is inactive. It too has no code and no key, so that balance is
|
||||
// stranded — NOT burned. Supply is unchanged by it.
|
||||
export const FEE_COINBASE = '0x0100000000000000000000000000000000000000';
|
||||
|
||||
const QUERY_KEY = 'cchain:feeSplit' as const;
|
||||
const POLL_INTERVAL_MS = 30_000;
|
||||
const SERIES_LIMIT = 240;
|
||||
|
||||
export type FeeSplitStatus = 'active' | 'scheduled' | 'inactive' | 'unknown';
|
||||
|
||||
// The two fields of the EVM chain config that decide where a fee goes. `null`
|
||||
// for the whole object means the node does not answer eth_getChainConfig, which
|
||||
// is 'unknown' — never guess.
|
||||
export interface FeeSplitChainConfig {
|
||||
readonly feeSplitTimestamp?: number;
|
||||
readonly allowFeeRecipients?: boolean;
|
||||
}
|
||||
|
||||
export interface FeeSplitReading {
|
||||
readonly status: FeeSplitStatus;
|
||||
// Vault balance in wei: rewards accrued exactly, supply burned to within one
|
||||
// wei per transaction.
|
||||
readonly vaultWei: string;
|
||||
// Coinbase balance in wei. Only meaningful while the split is inactive.
|
||||
readonly coinbaseWei: string;
|
||||
readonly blockNumber: number;
|
||||
// Head block timestamp in seconds — the value the activation gate reads.
|
||||
readonly blockTime: number;
|
||||
// feeSplitTimestamp, or null when the config does not set it (nil = never).
|
||||
readonly activatesAt: number | null;
|
||||
readonly allowFeeRecipients: boolean;
|
||||
}
|
||||
|
||||
export interface FeeSplitSample {
|
||||
readonly blockNumber: number;
|
||||
readonly blockTime: number;
|
||||
readonly vaultWei: string;
|
||||
}
|
||||
|
||||
export interface FeeSplitData {
|
||||
readonly reading: FeeSplitReading;
|
||||
readonly series: ReadonlyArray<FeeSplitSample>;
|
||||
}
|
||||
|
||||
// ── Activation gate ──
|
||||
|
||||
// Mirrors extras.ChainConfig.IsFeeSplit(time): a nil timestamp is never, and
|
||||
// otherwise the split is on once time >= timestamp.
|
||||
//
|
||||
// The gate is evaluated against the HEAD BLOCK's timestamp, not the wall clock,
|
||||
// because block time is what the node passes to IsFeeSplit. A chain whose
|
||||
// activation timestamp has passed in wall-clock terms but which has not built a
|
||||
// block since has not split a single fee, and must not be reported as active.
|
||||
export function deriveStatus(chainConfig: FeeSplitChainConfig | null, blockTime: number): FeeSplitStatus {
|
||||
if (chainConfig === null) {
|
||||
return 'unknown';
|
||||
}
|
||||
const activatesAt = chainConfig.feeSplitTimestamp;
|
||||
if (activatesAt === undefined || activatesAt === null) {
|
||||
return 'inactive';
|
||||
}
|
||||
return blockTime >= activatesAt ? 'active' : 'scheduled';
|
||||
}
|
||||
|
||||
// Supply burned, in wei.
|
||||
//
|
||||
// While the split is active this IS the vault balance: creditTxFee destroys
|
||||
// ceil(fee/2) and banks floor(fee/2), so burn >= vault by at most one wei per
|
||||
// transaction, and reporting the vault figure floors the burn rather than
|
||||
// inflating it.
|
||||
//
|
||||
// Before activation it is provably ZERO — creditTxFee has not destroyed a single
|
||||
// wei — so the vault balance must not be read as a burn. The vault is a plain
|
||||
// account; anyone can transfer into it, and calling that "burned" would invent
|
||||
// exactly the deflation this panel exists to report honestly.
|
||||
//
|
||||
// Undefined when the activation state could not be verified. A dash is the
|
||||
// correct answer to a question we did not get to ask.
|
||||
export function burnedWei(reading: FeeSplitReading | undefined): string | undefined {
|
||||
if (!reading || reading.status === 'unknown') {
|
||||
return undefined;
|
||||
}
|
||||
return reading.status === 'active' ? reading.vaultWei : '0';
|
||||
}
|
||||
|
||||
// ── Series ──
|
||||
|
||||
// One observation per head block, kept for the browsing session.
|
||||
//
|
||||
// Historical state is not available on the fleet — a balance query even one
|
||||
// block back answers "missing trie node" — and the indexer that would otherwise
|
||||
// hold coin-balance history is not serving. So the only honest series is what
|
||||
// this session observes. Module scope, not component state, so it survives
|
||||
// navigating away and back inside the SPA; deduplicated on block number because
|
||||
// the balance cannot change without a new block.
|
||||
const observations: Array<FeeSplitSample> = [];
|
||||
|
||||
export function recordSample(
|
||||
reading: FeeSplitReading,
|
||||
into: Array<FeeSplitSample> = observations,
|
||||
): ReadonlyArray<FeeSplitSample> {
|
||||
const last = into[into.length - 1];
|
||||
if (!last || last.blockNumber !== reading.blockNumber) {
|
||||
into.push({
|
||||
blockNumber: reading.blockNumber,
|
||||
blockTime: reading.blockTime,
|
||||
vaultWei: reading.vaultWei,
|
||||
});
|
||||
if (into.length > SERIES_LIMIT) {
|
||||
into.shift();
|
||||
}
|
||||
}
|
||||
// A fresh array every call: the chart re-renders on identity, and the caller
|
||||
// must never be handed the buffer itself.
|
||||
return into.slice();
|
||||
}
|
||||
|
||||
// Wei -> whole coin, for plotting.
|
||||
//
|
||||
// THE DIVISOR IS 10^18, NOT 10^9. nLUX (10^9) is the P-Chain staking
|
||||
// denomination; this is a C-Chain EVM account balance from eth_getBalance, which
|
||||
// is wei. config.chain.currency.decimals is the one place that number lives —
|
||||
// read it, never hard-code. Proof from live mainnet: the fee coinbase holds
|
||||
// 3867543864740765677447 wei = 3,867.54 LUX. At 10^9 it would read 3.87 trillion
|
||||
// LUX, roughly double the entire supply.
|
||||
export function toCoinSeries(series: ReadonlyArray<FeeSplitSample>): Array<{ date: Date; value: number }> {
|
||||
const divisor = new BigNumber(10).pow(config.chain.currency.decimals);
|
||||
return series.map((sample) => ({
|
||||
date: new Date(sample.blockTime * 1000),
|
||||
value: new BigNumber(sample.vaultWei).div(divisor).toNumber(),
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Wire ──
|
||||
|
||||
export interface RpcEnvelope {
|
||||
readonly id?: number;
|
||||
readonly result?: unknown;
|
||||
readonly error?: { readonly message?: string };
|
||||
}
|
||||
|
||||
const ID_VAULT = 1;
|
||||
const ID_COINBASE = 2;
|
||||
const ID_HEAD = 3;
|
||||
const ID_CHAIN_CONFIG = 4;
|
||||
|
||||
const RPC_BATCH = [
|
||||
{ jsonrpc: '2.0', id: ID_VAULT, method: 'eth_getBalance', params: [ FEE_REWARD_VAULT, 'latest' ] },
|
||||
{ jsonrpc: '2.0', id: ID_COINBASE, method: 'eth_getBalance', params: [ FEE_COINBASE, 'latest' ] },
|
||||
{ jsonrpc: '2.0', id: ID_HEAD, method: 'eth_getBlockByNumber', params: [ 'latest', false ] },
|
||||
// luxfi/evm extension. A node that does not serve it answers "method not
|
||||
// found", which is the honest 'unknown' state rather than a failure.
|
||||
{ jsonrpc: '2.0', id: ID_CHAIN_CONFIG, method: 'eth_getChainConfig', params: [] },
|
||||
];
|
||||
|
||||
// Required results throw on a JSON-RPC error rather than reading as zero: a
|
||||
// balance the node refused to give is not a zero balance, and a burn counter
|
||||
// that silently reads zero on failure is the worst possible lie.
|
||||
function required(batch: ReadonlyArray<RpcEnvelope>, id: number, what: string): unknown {
|
||||
const entry = batch.find((item) => item.id === id);
|
||||
if (!entry || entry.error || entry.result === undefined || entry.result === null) {
|
||||
const detail = entry?.error?.message ? `: ${ entry.error.message }` : '';
|
||||
throw new Error(`C-Chain RPC did not return ${ what }${ detail }`);
|
||||
}
|
||||
return entry.result;
|
||||
}
|
||||
|
||||
function hexToWeiString(value: unknown, what: string): string {
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error(`C-Chain RPC returned a non-hex ${ what }`);
|
||||
}
|
||||
return BigInt(value).toString();
|
||||
}
|
||||
|
||||
function hexToNumber(value: unknown, what: string): number {
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error(`C-Chain RPC returned a non-hex ${ what }`);
|
||||
}
|
||||
return Number(BigInt(value));
|
||||
}
|
||||
|
||||
export function parseFeeSplitBatch(batch: ReadonlyArray<RpcEnvelope>): FeeSplitReading {
|
||||
const head = required(batch, ID_HEAD, 'the head block') as { number?: string; timestamp?: string };
|
||||
|
||||
const configEntry = batch.find((item) => item.id === ID_CHAIN_CONFIG);
|
||||
const chainConfig = (configEntry && !configEntry.error && configEntry.result ?
|
||||
configEntry.result : null) as FeeSplitChainConfig | null;
|
||||
|
||||
const blockTime = hexToNumber(head.timestamp, 'head block timestamp');
|
||||
|
||||
return {
|
||||
status: deriveStatus(chainConfig, blockTime),
|
||||
vaultWei: hexToWeiString(required(batch, ID_VAULT, 'the fee vault balance'), 'balance'),
|
||||
coinbaseWei: hexToWeiString(required(batch, ID_COINBASE, 'the coinbase balance'), 'balance'),
|
||||
blockNumber: hexToNumber(head.number, 'head block number'),
|
||||
blockTime,
|
||||
activatesAt: chainConfig?.feeSplitTimestamp ?? null,
|
||||
allowFeeRecipients: chainConfig?.allowFeeRecipients === true,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchFeeSplit(): Promise<FeeSplitData> {
|
||||
const url = config.chain.rpcUrls[0];
|
||||
if (!url) {
|
||||
throw new Error('No C-Chain RPC URL is configured');
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(RPC_BATCH),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`C-Chain RPC returned ${ res.status }`);
|
||||
}
|
||||
|
||||
const batch = await res.json() as unknown;
|
||||
if (!Array.isArray(batch)) {
|
||||
throw new Error('C-Chain RPC did not answer the batch');
|
||||
}
|
||||
|
||||
const reading = parseFeeSplitBatch(batch as ReadonlyArray<RpcEnvelope>);
|
||||
|
||||
return { reading, series: recordSample(reading) };
|
||||
}
|
||||
|
||||
// ── Hook ──
|
||||
|
||||
export function useFeeSplit() {
|
||||
const query = useQuery({
|
||||
queryKey: [ QUERY_KEY ],
|
||||
queryFn: fetchFeeSplit,
|
||||
staleTime: POLL_INTERVAL_MS,
|
||||
refetchInterval: POLL_INTERVAL_MS,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
return {
|
||||
reading: query.data?.reading,
|
||||
series: query.data?.series,
|
||||
isLoading: query.isLoading,
|
||||
isError: query.isError,
|
||||
};
|
||||
}
|
||||
@@ -23,6 +23,17 @@ const moduleExports = {
|
||||
'@hanzogui/web',
|
||||
],
|
||||
reactStrictMode: true,
|
||||
// `next dev` runs Turbopack, which does not read the webpack() block below, so
|
||||
// every `import Icon from 'icons/*.svg'` resolved to a static-image OBJECT and
|
||||
// React threw "Element type is invalid … but got: object". That crashed the
|
||||
// whole page for any component importing an SVG directly — Hint, ChartWidget,
|
||||
// toolkit/next/link, FilterInput, BackToButton — i.e. most of the app, in dev
|
||||
// only. Same loader, declared once per bundler, so dev renders what prod ships.
|
||||
turbopack: {
|
||||
rules: {
|
||||
'*.svg': { loaders: [ '@svgr/webpack' ], as: '*.js' },
|
||||
},
|
||||
},
|
||||
webpack(config) {
|
||||
config.module.rules.push(
|
||||
{
|
||||
|
||||
@@ -14,6 +14,7 @@ import HeroBanner from 'ui/home/HeroBanner';
|
||||
import LatestBlocks from 'ui/home/LatestBlocks';
|
||||
import Stats from 'ui/home/Stats';
|
||||
import Transactions from 'ui/home/Transactions';
|
||||
import FeeSplitPanel from 'ui/stats/lux/FeeSplitPanel';
|
||||
|
||||
const PRIMARY_NETWORK_ID = '11111111111111111111111111111111LpoYY';
|
||||
// nLUX is NANO-LUX: 10^9 nLUX = 1 LUX. This read 6, inflating every LUX figure
|
||||
@@ -297,6 +298,16 @@ const NetworkOverview = () => {
|
||||
{ /* ── Stats widgets ── */ }
|
||||
<Stats/>
|
||||
|
||||
{ /*
|
||||
── Fee burn & staking rewards ──
|
||||
Every chain, not just the primary network: the panel reads the chain
|
||||
it is pointed at (vault balance + feeSplitTimestamp) and says so. It
|
||||
lives here rather than on /stats because /stats 404s outright when the
|
||||
stats microservice is not configured (getServerSideProps guards.stats),
|
||||
and burn is a chain fact that owes nothing to an indexer.
|
||||
*/ }
|
||||
<FeeSplitPanel/>
|
||||
|
||||
{ /* ── Latest blocks ── */ }
|
||||
<div className="rounded-lg border border-[var(--color-border-divider)] bg-[var(--color-stats-bg)] p-5 lg:p-6">
|
||||
<LatestBlocks/>
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
// Fee burn and staking rewards, both read from ONE balance.
|
||||
//
|
||||
// While the fee split is active, creditTxFee credits floor(fee/2) to the fee
|
||||
// reward vault and destroys the rest, so the vault's balance is simultaneously
|
||||
// the rewards counter and the burn counter — they differ by at most one wei per
|
||||
// transaction. That is why this panel needs no indexer, no event log and no
|
||||
// receipts: one eth_getBalance is the whole data set.
|
||||
//
|
||||
// It must also be honest when nothing is happening. On a chain where the split
|
||||
// has never fired the counters read zero, and a zero rendered without that
|
||||
// context reads as "burning, just slowly". Every state below says plainly which
|
||||
// one it is, and while the split is off the panel shows where the fees actually
|
||||
// go instead.
|
||||
|
||||
import { Heading } from '@luxfi/ui/heading';
|
||||
import { Skeleton } from '@luxfi/ui/skeleton';
|
||||
import { Tag } from '@luxfi/ui/tag';
|
||||
import React from 'react';
|
||||
|
||||
import config from 'configs/app';
|
||||
import type { FeeSplitStatus } from 'lib/api/cchain';
|
||||
import { useFeeSplit, burnedWei, toCoinSeries, FEE_REWARD_VAULT, FEE_COINBASE } from 'lib/api/cchain';
|
||||
import dayjs from 'lib/date/dayjs';
|
||||
import { currencyUnits } from 'lib/units';
|
||||
import { cn } from 'lib/utils/cn';
|
||||
import { ChartWidget } from 'toolkit/components/charts/ChartWidget';
|
||||
import { useChartsConfig } from 'ui/shared/chart/config';
|
||||
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
|
||||
import StatsWidget from 'ui/shared/stats/StatsWidget';
|
||||
import NativeCoinValue from 'ui/shared/value/NativeCoinValue';
|
||||
|
||||
const ACCURACY = 4;
|
||||
|
||||
const STATUS_LABEL: Record<FeeSplitStatus, string> = {
|
||||
active: 'Active',
|
||||
scheduled: 'Scheduled',
|
||||
inactive: 'Not active',
|
||||
unknown: 'Unverified',
|
||||
};
|
||||
|
||||
const STATUS_DOT: Record<FeeSplitStatus, string> = {
|
||||
active: 'bg-[var(--color-green-500)]',
|
||||
scheduled: 'bg-[var(--color-orange-500)]',
|
||||
inactive: 'bg-[var(--color-gray-400)]',
|
||||
unknown: 'bg-[var(--color-gray-400)]',
|
||||
};
|
||||
|
||||
function statusText(status: FeeSplitStatus, activatesAt: number | null): string {
|
||||
const coin = currencyUnits.ether;
|
||||
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return `Every transaction fee is split in half. One half accrues to the fee reward vault for the ` +
|
||||
`P-Chain staking-reward pool; the other half is destroyed and leaves the supply. Both figures ` +
|
||||
`below are read from that one balance.`;
|
||||
case 'scheduled':
|
||||
return `The split is scheduled for ${ dayjs((activatesAt ?? 0) * 1000).utc().format('llll') }. ` +
|
||||
`Until this chain builds a block at or after that time, the whole fee still goes to the block ` +
|
||||
`coinbase and no ${ coin } is burned.`;
|
||||
case 'inactive':
|
||||
return `The fee split is not active on ${ config.chain.name || 'this chain' }. Every transaction fee ` +
|
||||
`is credited to the block coinbase in full: nothing is burned, and the supply is not deflationary.`;
|
||||
case 'unknown':
|
||||
return `This node does not report a chain config, so whether the split is active cannot be verified ` +
|
||||
`here. The vault balance is shown exactly as read.`;
|
||||
}
|
||||
}
|
||||
|
||||
const FeeSplitPanel = () => {
|
||||
const { reading, series, isLoading, isError } = useFeeSplit();
|
||||
const chartsConfig = useChartsConfig();
|
||||
|
||||
// Undefined until the first read lands: 'unknown' is a verified answer
|
||||
// ("this node will not tell us"), not a stand-in for "still loading".
|
||||
const status = reading?.status;
|
||||
const isActive = status === 'active';
|
||||
// Legacy routing is in force and provably lands in one account: the coinbase
|
||||
// is only a single address when block builders may not claim fees.
|
||||
const showsCoinbase = (status === 'inactive' || status === 'scheduled') && !reading?.allowFeeRecipients;
|
||||
|
||||
const charts = React.useMemo(() => [ {
|
||||
id: 'fee-vault',
|
||||
name: 'Fee reward vault',
|
||||
items: toCoinSeries(series ?? []),
|
||||
charts: chartsConfig,
|
||||
units: currencyUnits.ether,
|
||||
} ], [ series, chartsConfig ]);
|
||||
|
||||
// Zero before activation, the vault balance after it, and a dash when the
|
||||
// activation state could not be read at all — never the raw balance on a
|
||||
// chain that has burned nothing. See burnedWei().
|
||||
const burned = burnedWei(reading);
|
||||
|
||||
if (isError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mt-6 sm:mt-8">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Heading level="3" className="text-sm">Fee burn & staking rewards</Heading>
|
||||
{ status && (
|
||||
<Tag
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
startElement={ <span className={ cn('inline-block size-1.5 rounded-full', STATUS_DOT[status]) }/> }
|
||||
>
|
||||
{ STATUS_LABEL[status] }
|
||||
</Tag>
|
||||
) }
|
||||
</div>
|
||||
|
||||
<Skeleton loading={ isLoading } className="mb-3">
|
||||
<p className="text-xs text-[var(--color-text-secondary)] max-w-[80ch]">
|
||||
{ status ? statusText(status, reading?.activatesAt ?? null) : 'Reading the fee reward vault.' }
|
||||
</p>
|
||||
</Skeleton>
|
||||
|
||||
{ /*
|
||||
sm/lg, never md: this repo redefines sm=415px and lg=1000px in
|
||||
nextjs/global.css, and the generated `md` rules land AFTER `lg`, so
|
||||
`md:grid-cols-2 lg:grid-cols-3` measured 682px 682px at 1440px — two
|
||||
columns, with the third card orphaned on its own row.
|
||||
*/ }
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<StatsWidget
|
||||
label={ `${ currencyUnits.ether } burned` }
|
||||
icon="flame"
|
||||
value={ burned === undefined && status ?
|
||||
'\u2014' :
|
||||
<NativeCoinValue amount={ burned } accuracy={ ACCURACY } loading={ isLoading }/> }
|
||||
isLoading={ isLoading }
|
||||
isFallback={ !isActive }
|
||||
hint={
|
||||
`Supply destroyed by the fee split: the half of every fee that is credited to no account at all, ` +
|
||||
`so it leaves the total supply. Equal to the vault balance to within one wei per transaction, and ` +
|
||||
`never rounded up. Exactly zero until the split activates — no fee has been destroyed before then.`
|
||||
}
|
||||
/>
|
||||
<StatsWidget
|
||||
label="Staking rewards accrued"
|
||||
icon="lightning"
|
||||
value={ <NativeCoinValue amount={ reading?.vaultWei } accuracy={ ACCURACY } loading={ isLoading }/> }
|
||||
isLoading={ isLoading }
|
||||
isFallback={ !isActive }
|
||||
hint={
|
||||
`The other half of every fee, parked in the protocol-owned fee reward vault for the P-Chain ` +
|
||||
`staking-reward pool. Still in circulation — moved, never minted.`
|
||||
}
|
||||
/>
|
||||
{ showsCoinbase && (
|
||||
<StatsWidget
|
||||
label="Held by the fee coinbase"
|
||||
icon="gas"
|
||||
value={ <NativeCoinValue amount={ reading?.coinbaseWei } accuracy={ ACCURACY } loading={ isLoading }/> }
|
||||
isLoading={ isLoading }
|
||||
hint={
|
||||
`Where fees go while the split is off. This coinbase has no code and no private key, so the ` +
|
||||
`${ currencyUnits.ether } credited to it is stranded, not burned — the supply is unchanged by it.`
|
||||
}
|
||||
/>
|
||||
) }
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 mt-3 text-2xs text-[var(--color-text-secondary)]">
|
||||
<span>Fee reward vault</span>
|
||||
<AddressEntity address={{ hash: FEE_REWARD_VAULT }} noIcon noCopy truncation="constant" className="text-2xs"/>
|
||||
{ showsCoinbase && (
|
||||
<>
|
||||
<span>{ '\u00B7' }</span>
|
||||
<span>coinbase</span>
|
||||
<AddressEntity address={{ hash: FEE_COINBASE }} noIcon noCopy truncation="constant" className="text-2xs"/>
|
||||
</>
|
||||
) }
|
||||
{ reading && (
|
||||
<>
|
||||
<span>{ '\u00B7' }</span>
|
||||
<span>read at block #{ reading.blockNumber.toLocaleString() }</span>
|
||||
</>
|
||||
) }
|
||||
</div>
|
||||
|
||||
{ isActive ? (
|
||||
<ChartWidget
|
||||
className="h-[240px] mt-4"
|
||||
title="Fee reward vault"
|
||||
description="Vault balance, sampled once per block while this page is open. Nodes prune state, so no history can be backfilled."
|
||||
charts={ charts }
|
||||
isLoading={ isLoading }
|
||||
isError={ false }
|
||||
emptyText="Not enough samples yet — the vault is read once per new block while this page is open."
|
||||
/>
|
||||
) : status && (
|
||||
<p className="mt-3 text-2xs text-[var(--color-text-secondary)]">
|
||||
The vault chart appears once the split is active — until then there is nothing to plot but a flat zero.
|
||||
</p>
|
||||
) }
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(FeeSplitPanel);
|
||||
Reference in New Issue
Block a user