fix(explore): a block's fee label must follow the policy at THAT block

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]>
This commit is contained in:
2026-07-28 01:09:44 -07:00
co-authored by Hanzo Dev
parent d556673dff
commit 56c0e129b2
7 changed files with 153 additions and 16 deletions
+90 -1
View File
@@ -16,7 +16,17 @@
import { describe, expect, it } from 'vitest';
import type { FeeSplitReading, RpcEnvelope } from './useFeeSplit';
import { burnedWei, deriveFeeDestination, deriveStatus, feeDestinationCopy, parseFeeSplitBatch, recordSample, toCoinSeries } from './useFeeSplit';
import {
blockTimeSeconds,
burnedWei,
deriveFeeDestination,
deriveStatus,
feeDestinationAt,
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,
@@ -213,3 +223,82 @@ describe('deriveFeeDestination — "Burnt fees" is a claim, not a label', () =>
expect(feeDestinationCopy('burned').label).toBe('Burnt fees');
});
});
// A block's label must follow the policy in force AT THAT BLOCK. Deriving it
// from the head reading is only accidentally right while the split has never
// been active; the day feeSplitTimestamp passes, every pre-activation block
// would be relabelled "Burnt fees" — the same false deflation claim, moved into
// history. These cases pin the per-block gate that prevents that.
describe('per-block fee destination — history must not be relabelled', () => {
const ACTIVATES_AT = 1_800_000_000;
const config = { feeSplitTimestamp: ACTIVATES_AT };
it('does not call a pre-activation block burned once the split is live', () => {
// Head is past activation, the rendered block is not.
expect(deriveFeeDestination(deriveStatus(config, ACTIVATES_AT + 5_000))).toBe('burned');
expect(deriveFeeDestination(deriveStatus(config, ACTIVATES_AT - 1))).toBe('coinbase');
});
it('suppresses the burn ratio on a pre-activation block', () => {
const copy = feeDestinationCopy(deriveFeeDestination(deriveStatus(config, ACTIVATES_AT - 1)));
expect(copy.showBurnRatio).toBe(false);
expect(copy.label).not.toContain('Burnt');
});
it('calls the activation block itself burned — the gate is >=, as in IsFeeSplit', () => {
expect(deriveFeeDestination(deriveStatus(config, ACTIVATES_AT))).toBe('burned');
});
it('reads an API timestamp as whole seconds', () => {
// Block 1098234, the page that rendered "Burnt fees" over coinbase 0x0100..0000.
expect(blockTimeSeconds('2026-07-28T07:28:35.000000Z')).toBe(1785223715);
});
it('has no block time when the API omits it, so the caller falls back to head', () => {
expect(blockTimeSeconds(null)).toBeUndefined();
expect(blockTimeSeconds(undefined)).toBeUndefined();
expect(blockTimeSeconds('')).toBeUndefined();
expect(blockTimeSeconds('not a date')).toBeUndefined();
});
});
// The wiring, not just the arithmetic: feeDestinationAt is what the block pages
// call. Reusing reading.status here instead of re-deriving against the block's
// own time is exactly the regression these cases exist to catch.
describe('feeDestinationAt — the reading is the head, the question is the block', () => {
const ACTIVATES_AT = 1_800_000_000;
// A head reading taken well AFTER activation: status is 'active'.
const headAfterActivation: FeeSplitReading = {
status: 'active',
vaultWei: '0x0',
coinbaseWei: '0x0',
blockNumber: 2_000_000,
blockTime: ACTIVATES_AT + 100_000,
activatesAt: ACTIVATES_AT,
allowFeeRecipients: false,
};
it('does NOT report a pre-activation block as burned', () => {
expect(feeDestinationAt(headAfterActivation, ACTIVATES_AT - 1)).toBe('coinbase');
});
it('still reports a post-activation block as burned', () => {
expect(feeDestinationAt(headAfterActivation, ACTIVATES_AT + 1)).toBe('burned');
});
it('falls back to the head reading when the block time is unknown', () => {
expect(feeDestinationAt(headAfterActivation, undefined)).toBe('burned');
});
it('makes no claim at all with no reading', () => {
expect(feeDestinationAt(undefined, ACTIVATES_AT + 1)).toBe('unknown');
});
it('reports coinbase for every block on a chain that never set the timestamp', () => {
// Mainnet 96369 today. Head and history alike: nothing has been burned.
const neverActivated: FeeSplitReading = { ...headAfterActivation, status: 'inactive', activatesAt: null };
expect(feeDestinationAt(neverActivated, 1)).toBe('coinbase');
expect(feeDestinationAt(neverActivated, ACTIVATES_AT + 100_000)).toBe('coinbase');
});
});
+48 -4
View File
@@ -182,14 +182,58 @@ export function feeDestinationCopy(destination: FeeDestination): FeeDestinationC
}
/**
* The fee destination for the current chain, read live.
* 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(): FeeDestination {
const { reading } = useFeeSplit();
return reading ? deriveFeeDestination(reading.status) : 'unknown';
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 ──
+5 -4
View File
@@ -11,7 +11,7 @@ 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 { blockTimeSeconds, 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';
@@ -59,12 +59,13 @@ 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;
// Whether THIS block's base fee was destroyed, read live and gated on this
// block's own timestamp — not on the policy in force at the head. Never assumed.
const feeCopy = feeDestinationCopy(useFeeDestination(blockTimeSeconds(data?.timestamp)));
const handlePrevNextClick = React.useCallback((direction: 'prev' | 'next') => {
if (!data) {
return;
+2 -2
View File
@@ -10,7 +10,7 @@ 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 { blockTimeSeconds, feeDestinationCopy, useFeeDestination } from 'lib/api/cchain/useFeeSplit';
import getBlockTotalReward from 'lib/block/getBlockTotalReward';
import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle';
import { currencyUnits } from 'lib/units';
@@ -38,7 +38,7 @@ 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 feeCopy = feeDestinationCopy(useFeeDestination(blockTimeSeconds(data.timestamp)));
const totalReward = getBlockTotalReward(data);
const burntFees = BigNumber(data.burnt_fees || 0);
const txFees = BigNumber(data.transaction_fees || 0);
+3
View File
@@ -35,6 +35,9 @@ 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.
// No block time: this is a column header spanning many blocks, so it can only
// report the policy in force at the head. The per-row label and burn ratio are
// each gated on their own block's timestamp — see BlocksTableItem.
const feeCopy = feeDestinationCopy(useFeeDestination());
const initialList = useInitialList({
data: data ?? [],
+3 -3
View File
@@ -10,7 +10,7 @@ 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 { blockTimeSeconds, feeDestinationCopy, useFeeDestination } from 'lib/api/cchain/useFeeSplit';
import getBlockTotalReward from 'lib/block/getBlockTotalReward';
import { Link } from 'toolkit/next/link';
import BlockGasUsed from 'ui/shared/block/BlockGasUsed';
@@ -35,9 +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
// A live read gated on THIS block's timestamp, not an assumption: base fees are
// only destroyed while the fee split is active. See lib/api/cchain/useFeeSplit.
const feeCopy = feeDestinationCopy(useFeeDestination());
const feeCopy = feeDestinationCopy(useFeeDestination(blockTimeSeconds(data.timestamp)));
const totalReward = getBlockTotalReward(data);
const burntFees = BigNumber(data.burnt_fees || 0);
const txFees = BigNumber(data.transaction_fees || 0);
+2 -2
View File
@@ -4,7 +4,7 @@ import React from 'react';
import type { Transaction } from 'types/api/transaction';
import config from 'configs/app';
import { feeDestinationCopy, useFeeDestination } from 'lib/api/cchain/useFeeSplit';
import { blockTimeSeconds, 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';
@@ -20,7 +20,7 @@ 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());
const feeCopy = feeDestinationCopy(useFeeDestination(blockTimeSeconds(data.timestamp)));
if (config.UI.views.tx.hiddenFields?.burnt_fees || (rollupFeature.isEnabled && rollupFeature.type === 'optimistic')) {
return null;