mirror of
https://github.com/luxfi/explore.git
synced 2026-09-24 00:28:18 +00:00
The fee-destination gate landed head-anchored: useFeeDestination() read
reading.status, which deriveStatus() computes against the HEAD block's
timestamp, and every per-block surface then rendered that one verdict.
Head status answers "is the split active NOW". A block page asks "was THIS
block's fee burned". They agree only while the split has never been active —
which is why this is invisible today: mainnet 96369 has no feeSplitTimestamp
at all (eth_getChainConfig answers, chainId 96369 present, and feeSplit
appears nowhere in it including upgrades/precompileUpgrades/stateUpgrades),
so every block correctly reads "Fees to coinbase".
The day feeSplitTimestamp is set and passes, all ~1.1M pre-activation blocks
would be relabelled "Burnt fees" with a burn ratio — the same false deflation
claim this module exists to remove, moved into history, and this time about
blocks whose fees demonstrably went to the coinbase 0x0100..0000 (3868.48 LUX
and climbing, measured at head 1098308).
So the destination is derived per block:
feeDestinationAt(reading, blockTime) pure; re-derives the gate against the
block's own time via deriveStatus
useFeeDestination(blockTime?) one-line wrapper over it
blockTimeSeconds(apiTimestamp) ISO -> seconds, undefined when absent
Passed at the four surfaces that name one block (block details, blocks table
row, blocks list row, tx details). BlocksTable keeps the head reading with a
comment saying why: a column header spans many blocks, so it cannot be a
per-block claim, and the per-row label and ratio are each gated individually.
A missing timestamp falls back to the head reading, so a loading row keeps
today's behaviour rather than flashing "destination unknown".
Decomplected on purpose: the decision is a pure function of (reading,
blockTime), so it is tested without a React tree. Reverting the body to
deriveFeeDestination(reading.status) fails "does NOT report a pre-activation
block as burned" — the regression is pinned, not asserted.
32/32 vitest green (5 new cases); tsc reports 0 errors in all 7 touched files
and eslint is clean on them (control: 232 pre-existing tsc errors, none in
these files).
Co-authored-by: Hanzo Dev <[email protected]>
399 lines
15 KiB
TypeScript
399 lines
15 KiB
TypeScript
// 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';
|
|
}
|
|
|
|
// ── Where a fee actually goes ──
|
|
|
|
// Every "Burnt fees" label in this explorer is a claim about deflation, and on
|
|
// this chain it is currently false. `creditTxFee` only destroys wei while the
|
|
// split is active; before activation it credits the WHOLE fee to the block
|
|
// coinbase, which is an account, so supply does not move. Measured on mainnet
|
|
// 96369 at block 1098244: eth_getChainConfig returns a config object (chainId
|
|
// 96369 present, so the call works) with NO feeSplitTimestamp, the vault
|
|
// 0x0100…0002 holds 0 LUX, and the coinbase 0x0100…0000 holds 3867.79 LUX and
|
|
// climbing. Not one wei has been burned, yet /block/1098234 rendered
|
|
// "Burnt fees 0.000525 LUX".
|
|
//
|
|
// So the label is derived, in this one place, from the same reading the fee
|
|
// panel uses. Callers ask once and render what they are told.
|
|
export type FeeDestination =
|
|
|
|
/** creditTxFee destroys ceil(fee/2): "burnt" is accurate. */
|
|
| 'burned' |
|
|
|
|
/** The whole fee is credited to the block coinbase: nothing is burned. */
|
|
'coinbase' |
|
|
|
|
/** The node did not answer. Make no claim either way. */
|
|
'unknown';
|
|
|
|
export function deriveFeeDestination(status: FeeSplitStatus): FeeDestination {
|
|
if (status === 'unknown') {
|
|
return 'unknown';
|
|
}
|
|
return status === 'active' ? 'burned' : 'coinbase';
|
|
}
|
|
|
|
export interface FeeDestinationCopy {
|
|
readonly label: string;
|
|
readonly hint: string;
|
|
|
|
/** False when a "share of fees burnt" ratio would assert something untrue. */
|
|
readonly showBurnRatio: boolean;
|
|
}
|
|
|
|
const COIN = () => config.chain.currency.symbol || 'the native token';
|
|
|
|
export function feeDestinationCopy(destination: FeeDestination): FeeDestinationCopy {
|
|
switch (destination) {
|
|
case 'burned':
|
|
return {
|
|
label: 'Burnt fees',
|
|
hint: `Amount of ${ COIN() } destroyed by the transactions in this block. ` +
|
|
'The fee split is active, so half of every fee is credited to no account and ' +
|
|
'the supply falls by that much. Equals Block Base Fee per Gas * Gas Used.',
|
|
showBurnRatio: true,
|
|
};
|
|
case 'coinbase':
|
|
return {
|
|
label: 'Fees to coinbase',
|
|
hint: `Base Fee per Gas * Gas Used, in ${ COIN() }. NOT burned: the fee split is ` +
|
|
`not active on this chain, so the whole fee is credited to the block coinbase ` +
|
|
`${ FEE_COINBASE }, an account with no code and no key. Total supply is unchanged.`,
|
|
showBurnRatio: false,
|
|
};
|
|
case 'unknown':
|
|
return {
|
|
label: 'Fees (destination unknown)',
|
|
hint: `Base Fee per Gas * Gas Used, in ${ COIN() }. This node did not answer ` +
|
|
'eth_getChainConfig, so whether the fee was burned or credited to the block ' +
|
|
'coinbase could not be established. No claim is made here.',
|
|
showBurnRatio: false,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Seconds since the epoch for an API timestamp, or `undefined` when it is
|
|
* absent or cannot be parsed. The gate reads seconds; the API sends ISO.
|
|
*/
|
|
export function blockTimeSeconds(timestamp: string | null | undefined): number | undefined {
|
|
if (!timestamp) {
|
|
return undefined;
|
|
}
|
|
const ms = Date.parse(timestamp);
|
|
return Number.isNaN(ms) ? undefined : Math.floor(ms / 1000);
|
|
}
|
|
|
|
/**
|
|
* The fee destination for ONE block, read live.
|
|
*
|
|
* `blockTime` is that block's timestamp in seconds. Pass it wherever the surface
|
|
* names a single block, because a fee is burned or not burned by the policy in
|
|
* force AT THAT BLOCK. Deriving from the head instead is only accidentally right
|
|
* while the split has never been active: the moment `feeSplitTimestamp` is set
|
|
* and passes, every one of the ~1.1M pre-activation blocks would be relabelled
|
|
* "Burnt fees" with a burn ratio — the same false deflation claim this module
|
|
* exists to remove, moved into history.
|
|
*
|
|
* Omit it only where the surface is not about one block (a column header), and
|
|
* while a block is still loading — then the head reading is the best available.
|
|
*
|
|
* Shares `useFeeSplit`'s query key, so a page that renders many rows issues one
|
|
* batch for all of them and the fee panel reuses the same cached reading.
|
|
*/
|
|
export function useFeeDestination(blockTime?: number): FeeDestination {
|
|
return feeDestinationAt(useFeeSplit().reading, blockTime);
|
|
}
|
|
|
|
/**
|
|
* The whole decision, as a pure function of a reading and a block time, so it
|
|
* can be tested without a React tree. `useFeeDestination` is a one-line wrapper.
|
|
*
|
|
* Note it re-derives the status against `blockTime` rather than reusing
|
|
* `reading.status`: that field is anchored to the head block, which answers a
|
|
* different question ("is the split active NOW") than the one a block page asks
|
|
* ("was THIS block's fee burned").
|
|
*/
|
|
export function feeDestinationAt(reading: FeeSplitReading | undefined, blockTime?: number): FeeDestination {
|
|
if (!reading) {
|
|
return 'unknown';
|
|
}
|
|
return deriveFeeDestination(deriveStatus(
|
|
{
|
|
feeSplitTimestamp: reading.activatesAt ?? undefined,
|
|
allowFeeRecipients: reading.allowFeeRecipients,
|
|
},
|
|
blockTime ?? reading.blockTime,
|
|
));
|
|
}
|
|
|
|
// ── 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,
|
|
};
|
|
}
|