Skip to content

Extension Points

Diego López León edited this page Jun 4, 2026 · 2 revisions

Extension Points

Everything this plugin plugs into Besu, in one place — both the new extension points the fork adds (which the plugin provides and Besu was taught to consult) and the standard Besu services the plugin consumes. All wiring happens in ClassicPlugin (register() provides services; start() consumes them).

Interfaces the fork adds live in the fork: diega/besu @ plugin-extensions. The standard services already exist in upstream Besu; the plugin just uses them. Classic* links point at this plugin's source.

Point Plugin side Registered via
Provided (fork's new points) NetworkProvider ClassicNetworkProvider addService
ForkIdProvider ClassicForkIdProvider addService
ProtocolScheduleCustomizer ClassicProtocolSpecs addService
Consumed (standard Besu services) PicoCLIOptions ClassicOptions getService
BlockchainService + BesuEvents ChainTracker getService

Mental model: addService = the plugin implements a point this fork added to Besu · getService = the plugin uses a service Besu already provides.


Provided — extension points added by the fork

NetworkProvider

Definition — plugin-api/.../services/NetworkProvider.java (@Unstable). Consulted as a fallback when --network=<name> is not a built-in network, so a plugin can add networks without touching the core enum.

public interface NetworkProvider extends BesuService {
  Optional<NetworkSpec> findNetwork(String networkName);   // empty ⇒ not ours
}
// datatypes — implemented by NetworkDefinition and by plugins
public interface NetworkSpec {
  BigInteger getNetworkId();
  URL getGenesisConfigUrl();
  boolean canSnapSync();
}

Implementation — ClassicNetworkProvider: findNetwork returns a NetworkSpec for classic (networkId 1) / mordor (networkId 7) over the bundled classic.json / mordor.json, else Optional.empty(). The chain id (61 / 63) is read from the genesis, not the provider. Makes --network=classic and --network=mordor resolve.

ForkIdProvider

Definition — plugin-api/.../services/ForkIdProvider.java. When a provider applies, the fork-ID manager uses its schedule instead of the built-in genesis options, so a non-mainnet chain advertises correct EIP-2124 fork IDs to peers. A single Optional-returning lookup (present ⇒ applies, empty ⇒ use genesis); chain ID is passed per call because the provider is registered before the active chain is known.

public interface ForkIdProvider extends BesuService {
  Optional<ForkSchedule> forkScheduleFor(BigInteger chainId);   // empty ⇒ not ours
  record ForkSchedule(List<Long> blockNumbers, List<Long> timestamps) {}
}

Implementation — ClassicForkIdProvider: forkScheduleFor returns a ForkSchedule for ETC chain ids via ClassicGenesisConfig.fromChainId (which parses the bundled classic.json / mordor.json and returns the blocks .distinct().sorted(); timestamps empty — ETC has no timestamp forks), else Optional.empty(). No fork block is hardcoded. (ClassicGenesisConfig also feeds ClassicProtocolSpecs — fork blocks and ecip1017EraRounds.)

ProtocolScheduleCustomizer

Definition — ethereum/core/.../mainnet/ProtocolScheduleCustomizer.java. The mechanism for injecting custom hardfork rules into the schedule.

public interface ProtocolScheduleCustomizer extends BesuService {
  // non-null: return an empty map to contribute nothing
  Map<Long, Function<ProtocolSpecBuilder, ProtocolSpecBuilder>> createAdapters(GenesisConfigOptions config);
}
  • Floor semantics: an adapter registered at block N applies to every milestone ≥ N until the next adapter entry; it receives the ProtocolSpecBuilder from the nearest prior milestone and mutates it.
  • Applied by MainnetProtocolSchedule.fromConfig(...), which queries the ServiceManager for the customizer and folds its map into the schedule.

Implementation — ClassicProtocolSpecs::createAdapters. Natively, ETC was wired straight into MainnetProtocolSpecFactory / ProtocolScheduleBuilder as a chain of inherited xxxDefinition methods; a plugin can't hook into that engine, so it was rewritten to emit adapters over the mainnet schedule. Net consensus state per fork is equivalent (verified), fork by fork:

Fork Block Delta over the mainnet builder
TangerineWhistle 2,500,000 replay protection (ECIP-1015)
DieHard 3,000,000 + DieHardGasCalculator (EIP-160) + paused bomb
Gotham 5,000,000 + ClassicBlockProcessor (ECIP-1017) + delayed bomb
DefuseBomb 5,900,000 + removed bomb (ECIP-1041)
Atlantis 8,772,000 ClassicBlockProcessor + EIP-100 difficulty
Thanos 11,700,000 + ECIP-1099 epoch headers + EIP-100
Mystique 14,525,000 + LondonGasCalculator + EIP-3541
Spiral 19,250,000 + ShanghaiGasCalculator + PUSH0 + warm coinbase

(Agharta / Phoenix / Magneto have no own entry — they inherit the prior adapter via floor semantics.)


Consumed — standard Besu services

These already exist in upstream Besu (any plugin uses them); the plugin obtains them with context.getService(...). They are not part of the fork.

PicoCLIOptions → ClassicOptions

Service — plugin-api/.../services/PicoCLIOptions.java. The standard way a plugin contributes CLI options.

Use — ClassicPlugin.register() calls getService(PicoCLIOptions.class).ifPresent(o -> o.addPicoCLIOptions("classic", options)), registering ClassicOptions under the classic namespace → the --plugin-classic-safe-block-depth (default 24) and --plugin-classic-finalized-block-depth (default 400) flags.

BlockchainService + BesuEvents → ChainTracker

Services — BlockchainService (read head/blocks, set safe & finalized) and BesuEvents (subscribe to added blocks).

Use — ClassicPlugin.start() seeds a ChainTracker from the current head and subscribes via besuEvents.addBlockAddedListener(...) to recompute on every canonical block. ChainTracker sets safe = head - 24 and finalized = head - 400 (configurable) through BlockchainService.

Why it's needed. ETC is PoW and has no consensus layer / Engine API. In Besu's post-merge codebase, safe and finalized are normally set by the CL (forkchoiceUpdated). With nobody setting them, blockchain.finalizedBlockHeader() / safeBlockHeader() stay empty, and any JSON-RPC call using the "finalized" or "safe" block tag returns UNKNOWN_BLOCK (AbstractBlockParameterMethod#posRelatedResult → orElseGet(UNKNOWN_BLOCK)). That breaks the many post-merge tools — wallets, indexers, bridges, ethers/web3 calls — that read those tags. ChainTracker is a minimal PoW "CL stub" that derives the labels by depth and sets them so those queries work.

On reorgs / finality. The labels are reactive — recomputed as max(0, head - depth) on every head update, with no memory of the previous value — so on a reorg they can move backwards. They are heuristic confirmation depths, not protocol finality, and they do not constrain deep reorgs: Besu's PoW path never rejects a reorg for being below finalized (that label only feeds a metric + these JSON-RPC tags), and the Bonsai trie-log pruner retains above min(finalized, head - maxLayersToLoad), so the plugin's finalized can only extend the retained window, never shrink it below Bonsai's default (~512 blocks). The only observable effect of a deep reorg is that safe/finalized over RPC may move backwards — consistent with there being no real finality on a PoW chain.


Widened core visibility

The feature also widens a few core types so the plugin can reuse/extend them (the entire content of the chore: increase visibility commit on plugin-extensions):

Type Change
AbstractBlockProcessor#rewardCoinbase package-private → protected (lets ClassicBlockProcessor override it cross-package)
AbstractBlockProcessor#blockReward package-private → protected (read by the subclass)
MainnetBlockHeaderValidator#createLegacyFeeMarketOmmerValidator both overloads → public (matches the already-public createPgaBlockHeaderValidator)
MainnetEVMs#registerIstanbulOperations → public

Not widened: MAX_GENERATION (the plugin vendors its own private static final int MAX_GENERATION = 6; — a stable spec constant) and the concrete *TransactionReceiptFactory classes (unused by the plugin).


Documents the plugin-extensions fork of diega/besu. Curated page — not auto-generated from a diff.