fix(explore): nothing has been burned — stop labelling the fee a burn

explore.lux.network/block/1098234 renders "Burnt fees 0.000525 LUX" on the
same page that names the recipient: Miner
0x0100000000000000000000000000000000000000. Both cannot be true. The block
list repeats it per row, with a "Burnt fees / Txn fees * 100%" ratio that
reads 100%, and the tx details page says it again. To an investor that is a
deflation claim, and it is false.

Measured on mainnet 96369 via https://api.lux.network/v1/bc/C/rpc at block
1098244 (eth_getChainConfig ANSWERS — chainId 96369 is in the result — so the
absence below is a real absence, not a failed call):

  feeSplitTimestamp     unset  -> IsFeeSplit(t) is false for every t
  vault  0x0100..0002   0 LUX  -> rewardShare has never been credited
  coinbase 0x0100..0000 3867.79 LUX and climbing (3867.55 an hour earlier)

luxfi/evm core/fee_split.go only destroys wei while the split is active;
before activation creditTxFee credits the WHOLE fee to the coinbase, which is
an account, so total supply does not move. Every wei of every fee this chain
has ever charged is sitting at 0x0100..0000. Zero has been burned.

The label is now derived, in the ONE place fee-split policy already lives
(lib/api/cchain/useFeeSplit.ts, alongside deriveStatus and burnedWei), from
the same cached reading the fee panel uses — so a page of 50 block rows still
issues one batch:

  active            -> "Burnt fees",                    burn ratio shown
  inactive/scheduled-> "Fees to coinbase",              ratio suppressed
  unknown           -> "Fees (destination unknown)",    ratio suppressed

'unknown' is a real third state: a node that will not answer
eth_getChainConfig has not told us the fee was burned, and it has not told us
it wasn't. Rendering either claim would be inventing one.

Surfaces: block details (label, hint, ratio, and the reward-breakdown
tooltip), blocks table header + rows, mobile blocks list, tx details.
5 new cases in useFeeSplit.spec.ts, 22/22 green; tsc clean on all seven
touched files.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-07-28 00:44:36 -07:00
co-authored by Hanzo Dev
parent 69b013ab40
commit d556673dff
7 changed files with 157 additions and 29 deletions
+31 -1
View File
@@ -16,7 +16,7 @@
import { describe, expect, it } from 'vitest';
import type { FeeSplitReading, RpcEnvelope } from './useFeeSplit';
import { burnedWei, deriveStatus, parseFeeSplitBatch, recordSample, toCoinSeries } from './useFeeSplit';
import { burnedWei, deriveFeeDestination, deriveStatus, feeDestinationCopy, 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,
@@ -183,3 +183,33 @@ describe('recordSample', () => {
expect(series[series.length - 1].blockNumber).toBe(299);
});
});
describe('deriveFeeDestination — "Burnt fees" is a claim, not a label', () => {
it('only calls it burned while the split is active', () => {
expect(deriveFeeDestination('active')).toBe('burned');
});
// Mainnet 96369 today: eth_getChainConfig answers (chainId present) with no
// feeSplitTimestamp, the vault holds 0 LUX and the coinbase 0x0100..0000
// holds 3867.79 LUX. Every wei of every fee went to an account.
it('says coinbase before activation, scheduled or not', () => {
expect(deriveFeeDestination('inactive')).toBe('coinbase');
expect(deriveFeeDestination('scheduled')).toBe('coinbase');
});
it('makes no claim when the config could not be read', () => {
expect(deriveFeeDestination('unknown')).toBe('unknown');
});
it('never shows a burn ratio for a fee that was not burned', () => {
expect(feeDestinationCopy('burned').showBurnRatio).toBe(true);
expect(feeDestinationCopy('coinbase').showBurnRatio).toBe(false);
expect(feeDestinationCopy('unknown').showBurnRatio).toBe(false);
});
it('never labels an un-burned fee "Burnt fees"', () => {
expect(feeDestinationCopy('coinbase').label).not.toContain('Burnt');
expect(feeDestinationCopy('unknown').label).not.toContain('Burnt');
expect(feeDestinationCopy('burned').label).toBe('Burnt fees');
});
});
+82
View File
@@ -110,6 +110,88 @@ export function burnedWei(reading: FeeSplitReading | undefined): string | undefi
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,
};
}
}
/**
* The fee destination for the current chain, read live.
*
* 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(): FeeDestination {
const { reading } = useFeeSplit();
return reading ? deriveFeeDestination(reading.status) : 'unknown';
}
// ── Series ──
// One observation per head block, kept for the browsing session.
+11 -10
View File
@@ -1,3 +1,6 @@
import { CollapsibleDetails } from '@luxfi/ui/collapsible';
import { Skeleton } from '@luxfi/ui/skeleton';
import { Tooltip } from '@luxfi/ui/tooltip';
import BigNumber from 'bignumber.js';
import { capitalize } from 'es-toolkit';
import { useRouter } from 'next/router';
@@ -8,16 +11,14 @@ import { ZKSYNC_L2_TX_BATCH_STATUSES } from 'types/api/zkSyncL2';
import { route, routeParams } from 'nextjs/routes';
import config from 'configs/app';
import { feeDestinationCopy, useFeeDestination } from 'lib/api/cchain/useFeeSplit';
import getBlockReward from 'lib/block/getBlockReward';
import { useMultichainContext } from 'lib/contexts/multichain';
import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle';
import * as arbitrum from 'lib/rollups/arbitrum';
import { formatZkSyncL2TxnBatchStatus, layerLabels } from 'lib/rollups/utils';
import getQueryParamString from 'lib/router/getQueryParamString';
import { CollapsibleDetails } from '@luxfi/ui/collapsible';
import { Link } from 'toolkit/next/link';
import { Skeleton } from '@luxfi/ui/skeleton';
import { Tooltip } from '@luxfi/ui/tooltip';
import { ZERO } from 'toolkit/utils/consts';
import { space } from 'toolkit/utils/htmlEntities';
import OptimisticL2TxnBatchDA from 'ui/shared/batch/OptimisticL2TxnBatchDA';
@@ -58,6 +59,9 @@ const BlockDetails = ({ query }: Props) => {
const router = useRouter();
const heightOrHash = getQueryParamString(router.query.height_or_hash);
const multichainContext = useMultichainContext();
// Whether the base fee is actually destroyed on this chain right now, read
// live. Decides the label below; it must never be assumed.
const feeCopy = feeDestinationCopy(useFeeDestination());
const { data, isPlaceholderData } = query;
@@ -105,7 +109,7 @@ const BlockDetails = ({ query }: Props) => {
{ !burntFees.isEqualTo(ZERO) && (
<>
{ space }-{ space }
<Tooltip content="Burnt fees">
<Tooltip content={ feeCopy.label }>
<span>{ burntFees.dividedBy(WEI).toFixed() }</span>
</Tooltip>
</>
@@ -498,13 +502,10 @@ const BlockDetails = ({ query }: Props) => {
{ !config.UI.views.block.hiddenFields?.burnt_fees && !burntFees.isEqualTo(ZERO) && (
<>
<DetailedInfo.ItemLabel
hint={
`Amount of ${ config.chain.currency.symbol || 'native token' } burned from transactions included in the block.
Equals Block Base Fee per Gas * Gas Used`
}
hint={ feeCopy.hint }
isLoading={ isPlaceholderData }
>
Burnt fees
{ feeCopy.label }
</DetailedInfo.ItemLabel>
<DetailedInfo.ItemValue multiRow>
<NativeCoinValue
@@ -514,7 +515,7 @@ const BlockDetails = ({ query }: Props) => {
startElement={ <IconSvg name="flame" className="w-5 h-5 text-[var(--color-icon-primary)] mr-1 lg:mr-2" isLoading={ isPlaceholderData }/> }
mr={ 4 }
/>
{ !txFees.isEqualTo(ZERO) && (
{ feeCopy.showBurnRatio && !txFees.isEqualTo(ZERO) && (
<Tooltip content="Burnt fees / Txn fees * 100%">
<Utilization
value={ burntFees.dividedBy(txFees).toNumber() }
+8 -4
View File
@@ -1,3 +1,5 @@
import { Skeleton } from '@luxfi/ui/skeleton';
import { Tooltip } from '@luxfi/ui/tooltip';
import BigNumber from 'bignumber.js';
import { capitalize } from 'es-toolkit';
import React from 'react';
@@ -8,12 +10,11 @@ import type { ClusterChainConfig } from 'types/multichain';
import { route } from 'nextjs-routes';
import config from 'configs/app';
import { feeDestinationCopy, useFeeDestination } from 'lib/api/cchain/useFeeSplit';
import getBlockTotalReward from 'lib/block/getBlockTotalReward';
import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle';
import { currencyUnits } from 'lib/units';
import { Link } from 'toolkit/next/link';
import { Skeleton } from '@luxfi/ui/skeleton';
import { Tooltip } from '@luxfi/ui/tooltip';
import BlockGasUsed from 'ui/shared/block/BlockGasUsed';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import BlockEntity from 'ui/shared/entities/block/BlockEntity';
@@ -35,6 +36,9 @@ interface Props {
const isRollup = config.features.rollup.isEnabled;
const BlocksListItem = ({ data, isLoading, enableTimeIncrement, animation, chainData }: Props) => {
// The label for this column is a live read, not an assumption: base fees are
// only destroyed while the fee split is active. See lib/api/cchain/useFeeSplit.
const feeCopy = feeDestinationCopy(useFeeDestination());
const totalReward = getBlockTotalReward(data);
const burntFees = BigNumber(data.burnt_fees || 0);
const txFees = BigNumber(data.transaction_fees || 0);
@@ -118,7 +122,7 @@ const BlocksListItem = ({ data, isLoading, enableTimeIncrement, animation, chain
) }
{ !isRollup && !config.UI.views.block.hiddenFields?.burnt_fees && (
<div>
<span className="font-medium">Burnt fees</span>
<span className="font-medium">{ feeCopy.label }</span>
<div className="flex gap-x-4 mt-2">
<NativeCoinValue
amount={ data.burnt_fees }
@@ -128,7 +132,7 @@ const BlocksListItem = ({ data, isLoading, enableTimeIncrement, animation, chain
display="flex"
color="text.secondary"
/>
<Utilization value={ burntFees.div(txFees).toNumber() } isLoading={ isLoading }/>
{ feeCopy.showBurnRatio && <Utilization value={ burntFees.div(txFees).toNumber() } isLoading={ isLoading }/> }
</div>
</div>
) }
+5 -2
View File
@@ -1,3 +1,4 @@
import { TableBody, TableColumnHeader, TableHeaderSticky, TableRoot, TableRow } from '@luxfi/ui/table';
import { capitalize } from 'es-toolkit';
import React from 'react';
@@ -5,11 +6,11 @@ import type { Block } from 'types/api/block';
import type { ClusterChainConfig } from 'types/multichain';
import config from 'configs/app';
import { feeDestinationCopy, useFeeDestination } from 'lib/api/cchain/useFeeSplit';
import { AddressHighlightProvider } from 'lib/contexts/addressHighlight';
import useInitialList from 'lib/hooks/useInitialList';
import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle';
import { currencyUnits } from 'lib/units';
import { TableBody, TableColumnHeader, TableHeaderSticky, TableRoot, TableRow } from '@luxfi/ui/table';
import BlocksTableItem from 'ui/blocks/BlocksTableItem';
import * as SocketNewItemsNotice from 'ui/shared/SocketNewItemsNotice';
import TimeFormatToggle from 'ui/shared/time/TimeFormatToggle';
@@ -33,6 +34,8 @@ const FEES_COL_WEIGHT = 22;
const isRollup = config.features.rollup.isEnabled;
const BlocksTable = ({ data, isLoading, top, page, showSocketInfo, socketInfoNum, showSocketErrorAlert, chainData }: Props) => {
// Column heading is a live read — see lib/api/cchain/useFeeSplit.
const feeCopy = feeDestinationCopy(useFeeDestination());
const initialList = useInitialList({
data: data ?? [],
idFn: (item) => item.height,
@@ -66,7 +69,7 @@ const BlocksTable = ({ data, isLoading, top, page, showSocketInfo, socketInfoNum
{ !isRollup && !config.UI.views.block.hiddenFields?.total_reward &&
<TableColumnHeader width={ `${ REWARD_COL_WEIGHT / widthBase * 100 }%` }>Reward { currencyUnits.ether }</TableColumnHeader> }
{ !isRollup && !config.UI.views.block.hiddenFields?.burnt_fees &&
<TableColumnHeader width={ `${ FEES_COL_WEIGHT / widthBase * 100 }%` }>Burnt fees { currencyUnits.ether }</TableColumnHeader> }
<TableColumnHeader width={ `${ FEES_COL_WEIGHT / widthBase * 100 }%` }>{ feeCopy.label } { currencyUnits.ether }</TableColumnHeader> }
{ !isRollup && !config.UI.views.block.hiddenFields?.base_fee &&
<TableColumnHeader width="150px" isNumeric>Base fee</TableColumnHeader> }
</TableRow>
+12 -6
View File
@@ -1,3 +1,6 @@
import { Skeleton } from '@luxfi/ui/skeleton';
import { TableCell, TableRow } from '@luxfi/ui/table';
import { Tooltip } from '@luxfi/ui/tooltip';
import BigNumber from 'bignumber.js';
import React from 'react';
@@ -7,11 +10,9 @@ import type { ClusterChainConfig } from 'types/multichain';
import { route } from 'nextjs-routes';
import config from 'configs/app';
import { feeDestinationCopy, useFeeDestination } from 'lib/api/cchain/useFeeSplit';
import getBlockTotalReward from 'lib/block/getBlockTotalReward';
import { Link } from 'toolkit/next/link';
import { Skeleton } from '@luxfi/ui/skeleton';
import { TableCell, TableRow } from '@luxfi/ui/table';
import { Tooltip } from '@luxfi/ui/tooltip';
import BlockGasUsed from 'ui/shared/block/BlockGasUsed';
import BlockPendingUpdateHint from 'ui/shared/block/BlockPendingUpdateHint';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
@@ -34,6 +35,9 @@ interface Props {
const isRollup = config.features.rollup.isEnabled;
const BlocksTableItem = ({ data, isLoading, enableTimeIncrement, animation, chainData }: Props) => {
// The label for this column is a live read, not an assumption: base fees are
// only destroyed while the fee split is active. See lib/api/cchain/useFeeSplit.
const feeCopy = feeDestinationCopy(useFeeDestination());
const totalReward = getBlockTotalReward(data);
const burntFees = BigNumber(data.burnt_fees || 0);
const txFees = BigNumber(data.transaction_fees || 0);
@@ -125,9 +129,11 @@ const BlocksTableItem = ({ data, isLoading, enableTimeIncrement, animation, chai
loading={ isLoading }
display="flex"
/>
<Tooltip content="Burnt fees / Txn fees * 100%" disabled={ isLoading }>
<Utilization className="mt-2 w-min" value={ burntFees.div(txFees).toNumber() } isLoading={ isLoading }/>
</Tooltip>
{ feeCopy.showBurnRatio && (
<Tooltip content="Burnt fees / Txn fees * 100%" disabled={ isLoading }>
<Utilization className="mt-2 w-min" value={ burntFees.div(txFees).toNumber() } isLoading={ isLoading }/>
</Tooltip>
) }
</TableCell>
) }
{ !isRollup && !config.UI.views.block.hiddenFields?.base_fee && data.base_fee_per_gas && (
+8 -6
View File
@@ -4,7 +4,7 @@ import React from 'react';
import type { Transaction } from 'types/api/transaction';
import config from 'configs/app';
import { currencyUnits } from 'lib/units';
import { feeDestinationCopy, useFeeDestination } from 'lib/api/cchain/useFeeSplit';
import { ZERO } from 'toolkit/utils/consts';
import * as DetailedInfo from 'ui/shared/DetailedInfo/DetailedInfo';
import DetailedInfoNativeCoinValue from 'ui/shared/DetailedInfo/DetailedInfoNativeCoinValue';
@@ -18,6 +18,9 @@ interface Props {
}
const TxDetailsBurntFees = ({ data, isLoading }: Props) => {
// Whether this fee was destroyed or credited to the block coinbase is a live
// read, never an assumption. See lib/api/cchain/useFeeSplit.
const feeCopy = feeDestinationCopy(useFeeDestination());
if (config.UI.views.tx.hiddenFields?.burnt_fees || (rollupFeature.isEnabled && rollupFeature.type === 'optimistic')) {
return null;
@@ -32,13 +35,12 @@ const TxDetailsBurntFees = ({ data, isLoading }: Props) => {
return (
<>
<DetailedInfo.ItemLabel
hint={ `
Amount of ${ currencyUnits.ether } burned for this transaction. Equals Block Base Fee per Gas * Gas Used
${ data.blob_gas_price && data.blob_gas_used ? ' + Blob Gas Price * Blob Gas Used' : '' }
` }
hint={ `${ feeCopy.hint }${
data.blob_gas_price && data.blob_gas_used ? ' Includes Blob Gas Price * Blob Gas Used.' : ''
}` }
isLoading={ isLoading }
>
Burnt fees
{ feeCopy.label }
</DetailedInfo.ItemLabel>
<DetailedInfoNativeCoinValue
amount={ value.toString() }