For AI agents: a documentation index is available at /llms.txt. Markdown versions of pages are available by appending .md to any documentation URL.
Skip to main content

Detect privately built transactions

In this tutorial, you'll build a complete Besu plugin from scratch, deploy it to a node, and watch it report live results.

The plugin watches the transactions your node sees gossiped in the public mempool, compares them to the transactions that arrive in each new block, and reports the difference. A transaction that appears in a block but was never seen in the mempool was likely built privately, for example by a maximal extractable value (MEV) builder that bypasses the public mempool.

note

This is a heuristic, not proof. A transaction can be missing from your node's view of the mempool for other reasons, such as your node starting recently, peering differences, or propagation timing. The signal is most meaningful once your node is fully synced and following the chain head.

Prerequisites

  • Java 25+. You can install Java using brew install openjdk@25 or manually install the Java JDK.
  • Gradle.
  • A Besu installation.
  • A consensus client, for example Teku, to run alongside Besu.

Steps

1. Set up your project

A Besu plugin is a standalone Java project. Create a new directory for it:

mkdir -p tx-detection-plugin/src/main/java/txdetection
cd tx-detection-plugin

By the end of the tutorial, your project will have the following structure:

tx-detection-plugin/
├── build.gradle
├── settings.gradle
└── src/
└── main/
└── java/
└── txdetection/
└── TxDetectionPlugin.java

2. Configure the build

Besu provides a Gradle plugin to simplify the plugin developer experience. It automatically adds and manages dependencies, and packages the plugin artifacts when you distribute the project.

In the root of your project, create build.gradle. Apply the latest version of the Gradle plugin (net.consensys.besu-plugin-distribution) and set the Besu version you want to compile your plugin against:

build.gradle
plugins {
id 'net.consensys.besu-plugin-distribution' version '0.2.1'
}

besuPlugin {
besuVersion = '26.6.0'
}

Create settings.gradle to name the project. The name determines the distribution ZIP file name:

settings.gradle
rootProject.name = 'tx-detection-plugin'

Generate the Gradle wrapper so the project builds with a consistent Gradle version:

gradle wrapper

3. Create the plugin skeleton

Every plugin implements the BesuPlugin interface, which has three required methods: register, start, and stop.

Create src/main/java/txdetection/TxDetectionPlugin.java with the following skeleton:

TxDetectionPlugin.java
package txdetection;

import com.google.auto.service.AutoService;
import org.hyperledger.besu.plugin.BesuPlugin;
import org.hyperledger.besu.plugin.ServiceManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

@AutoService(BesuPlugin.class)
public class TxDetectionPlugin implements BesuPlugin {

private static final Logger LOG = LoggerFactory.getLogger(TxDetectionPlugin.class);
private static final String PLUGIN_NAME = "tx-detection";

private ServiceManager serviceManager;

@Override
public String getName() {
return PLUGIN_NAME;
}

@Override
public void register(final ServiceManager serviceManager) {
// Store the ServiceManager and register early extension points.
this.serviceManager = serviceManager;
}

@Override
public void start() {
// Retrieve runtime services and begin work.
}

@Override
public void stop() {
// Remove listeners and release resources.
}
}

The @AutoService(BesuPlugin.class) annotation generates the service provider entry that Besu's ServiceLoader uses to discover your plugin at startup. Besu calls register(ServiceManager) once, early in startup, and passes the ServiceManager you use to access all Besu services. Store it in a field for later use.

4. Track the mempool

Add a Set that records the hash of every transaction your node sees in the mempool.

A busy node sees many transactions; use a bounded structure so memory usage stays constant. The following uses a fixed-size LinkedHashMap that evicts the oldest entry once it reaches its capacity, wrapped to make it thread-safe because Besu fires events from multiple threads:

TxDetectionPlugin.java
import org.hyperledger.besu.datatypes.Hash;
import org.hyperledger.besu.datatypes.Transaction;
import org.hyperledger.besu.plugin.services.BesuEvents;

import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;

@AutoService(BesuPlugin.class)
public class TxDetectionPlugin
implements BesuPlugin,
BesuEvents.TransactionAddedListener {

private static final Logger LOG = LoggerFactory.getLogger(TxDetectionPlugin.class);
private static final String PLUGIN_NAME = "tx-detection";
private static final int MAX_TRACKED_TRANSACTIONS = 100_000;

private ServiceManager serviceManager;

// Bounded, thread-safe set of transaction hashes seen in the mempool.
private final Set<Hash> mempoolHashes =
Collections.synchronizedSet(
Collections.newSetFromMap(
new LinkedHashMap<>() {
@Override
protected boolean removeEldestEntry(final Map.Entry<Hash, Boolean> eldest) {
return size() > MAX_TRACKED_TRANSACTIONS;
}
}));

@Override
public void onTransactionAdded(final Transaction transaction) {
mempoolHashes.add(transaction.getHash());
}
}

Implementing BesuEvents.TransactionAddedListener lets the plugin receive a callback every time a transaction is added to the node.

5. Inspect new blocks

Add the block listener that detects unseen transactions. For each new block that advances the chain head, count the transactions whose hash the plugin never recorded from the mempool:

TxDetectionPlugin.java
import org.hyperledger.besu.plugin.data.AddedBlockContext;

@AutoService(BesuPlugin.class)
public class TxDetectionPlugin
implements BesuPlugin,
BesuEvents.TransactionAddedListener,
BesuEvents.BlockAddedListener {

// ... fields and other methods ...

// Number of unseen transactions in the most recent block.
private volatile long detectedTxLastBlock;

@Override
public void onBlockAdded(final AddedBlockContext blockContext) {
// Only consider blocks that advance the canonical chain head.
if (blockContext.getEventType() != AddedBlockContext.EventType.HEAD_ADVANCED) {
return;
}

long unseen =
blockContext.getBlockBody().getTransactions().stream()
.map(Transaction::getHash)
.filter(hash -> !mempoolHashes.contains(hash))
.count();

detectedTxLastBlock = unseen;

if (unseen > 0) {
LOG.info(
"Block {} contained {} transaction(s) not seen in the mempool",
blockContext.getBlockHeader().getNumber(),
unseen);
}
}
}

Filtering on HEAD_ADVANCED avoids double-counting transactions from forks and reorganizations.

6. Register a metric category

Logging is useful, but metrics let you track the results over time. Register a metric category during register.

MetricCategoryRegistry must be used in register:

TxDetectionPlugin.java
import org.hyperledger.besu.plugin.services.metrics.MetricCategory;
import org.hyperledger.besu.plugin.services.metrics.MetricCategoryRegistry;

import java.util.Optional;

public class TxDetectionPlugin
implements BesuPlugin,
BesuEvents.TransactionAddedListener,
BesuEvents.BlockAddedListener {

// The category that groups this plugin's metrics.
private final MetricCategory metricCategory =
new MetricCategory() {
@Override
public String getName() {
return "tx_detection";
}

@Override
public Optional<String> getApplicationPrefix() {
return Optional.empty();
}
};

@Override
public void register(final ServiceManager serviceManager) {
this.serviceManager = serviceManager;
serviceManager
.getService(MetricCategoryRegistry.class)
.ifPresent(registry -> registry.addMetricCategory(metricCategory));
}
}

7. Create metrics and subscribe to events

In start, create the metrics and subscribe to the events. These runtime services only become available at start.

Use MetricsSystem to create a counter for the running total and a gauge for the most recent block, then use BesuEvents to register both listeners. Store the listener IDs in fields, so you can remove them in stop:

TxDetectionPlugin.java
import org.hyperledger.besu.plugin.services.MetricsSystem;
import org.hyperledger.besu.plugin.services.metrics.Counter;

public class TxDetectionPlugin
implements BesuPlugin,
BesuEvents.TransactionAddedListener,
BesuEvents.BlockAddedListener {

private long txListenerId;
private long blockListenerId;
private Counter detectedTxTotal;

@Override
public void start() {
serviceManager
.getService(MetricsSystem.class)
.ifPresent(
metrics -> {
detectedTxTotal =
metrics.createCounter(
metricCategory,
"total_detected",
"Total transactions imported in blocks but never seen in the mempool");
metrics.createLongGauge(
metricCategory,
"last_block",
"Transactions in the most recent block never seen in the mempool",
() -> detectedTxLastBlock);
});

serviceManager
.getService(BesuEvents.class)
.ifPresent(
events -> {
txListenerId = events.addTransactionAddedListener(this);
blockListenerId = events.addBlockAddedListener(this);
});

LOG.info("Transaction detection plugin started");
}

@Override
public void stop() {
serviceManager
.getService(BesuEvents.class)
.ifPresent(
events -> {
events.removeTransactionAddedListener(txListenerId);
events.removeBlockAddedListener(blockListenerId);
});
LOG.info("Transaction detection plugin stopped");
}
}

Next, update onBlockAdded to increment the counter when it finds unseen transactions. Add the highlighted lines inside the existing if (unseen > 0) block:

TxDetectionPlugin.java
if (unseen > 0) {
if (detectedTxTotal != null) {
detectedTxTotal.inc(unseen);
}
LOG.info(
"Block {} contained {} transaction(s) not seen in the mempool",
blockContext.getBlockHeader().getNumber(),
unseen);
}

8. Review the complete plugin

Your finished TxDetectionPlugin.java should look like this:

TxDetectionPlugin.java
package txdetection;

import com.google.auto.service.AutoService;
import org.hyperledger.besu.datatypes.Hash;
import org.hyperledger.besu.datatypes.Transaction;
import org.hyperledger.besu.plugin.BesuPlugin;
import org.hyperledger.besu.plugin.ServiceManager;
import org.hyperledger.besu.plugin.data.AddedBlockContext;
import org.hyperledger.besu.plugin.services.BesuEvents;
import org.hyperledger.besu.plugin.services.MetricsSystem;
import org.hyperledger.besu.plugin.services.metrics.Counter;
import org.hyperledger.besu.plugin.services.metrics.MetricCategory;
import org.hyperledger.besu.plugin.services.metrics.MetricCategoryRegistry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.Set;

@AutoService(BesuPlugin.class)
public class TxDetectionPlugin
implements BesuPlugin,
BesuEvents.TransactionAddedListener,
BesuEvents.BlockAddedListener {

private static final Logger LOG = LoggerFactory.getLogger(TxDetectionPlugin.class);
private static final String PLUGIN_NAME = "tx-detection";
private static final int MAX_TRACKED_TRANSACTIONS = 100_000;

private ServiceManager serviceManager;

private long txListenerId;
private long blockListenerId;

private Counter detectedTxTotal;
private volatile long detectedTxLastBlock;

// Bounded, thread-safe set of transaction hashes seen in the mempool.
private final Set<Hash> mempoolHashes =
Collections.synchronizedSet(
Collections.newSetFromMap(
new LinkedHashMap<>() {
@Override
protected boolean removeEldestEntry(final Map.Entry<Hash, Boolean> eldest) {
return size() > MAX_TRACKED_TRANSACTIONS;
}
}));

private final MetricCategory metricCategory =
new MetricCategory() {
@Override
public String getName() {
return "tx_detection";
}

@Override
public Optional<String> getApplicationPrefix() {
return Optional.empty();
}
};

@Override
public String getName() {
return PLUGIN_NAME;
}

@Override
public void register(final ServiceManager serviceManager) {
this.serviceManager = serviceManager;
serviceManager
.getService(MetricCategoryRegistry.class)
.ifPresent(registry -> registry.addMetricCategory(metricCategory));
}

@Override
public void start() {
serviceManager
.getService(MetricsSystem.class)
.ifPresent(
metrics -> {
detectedTxTotal =
metrics.createCounter(
metricCategory,
"total_detected",
"Total transactions imported in blocks but never seen in the mempool");
metrics.createLongGauge(
metricCategory,
"last_block",
"Transactions in the most recent block never seen in the mempool",
() -> detectedTxLastBlock);
});

serviceManager
.getService(BesuEvents.class)
.ifPresent(
events -> {
txListenerId = events.addTransactionAddedListener(this);
blockListenerId = events.addBlockAddedListener(this);
});

LOG.info("Transaction detection plugin started");
}

@Override
public void stop() {
serviceManager
.getService(BesuEvents.class)
.ifPresent(
events -> {
events.removeTransactionAddedListener(txListenerId);
events.removeBlockAddedListener(blockListenerId);
});
LOG.info("Transaction detection plugin stopped");
}

@Override
public void onTransactionAdded(final Transaction transaction) {
mempoolHashes.add(transaction.getHash());
}

@Override
public void onBlockAdded(final AddedBlockContext blockContext) {
if (blockContext.getEventType() != AddedBlockContext.EventType.HEAD_ADVANCED) {
return;
}

long unseen =
blockContext.getBlockBody().getTransactions().stream()
.map(Transaction::getHash)
.filter(hash -> !mempoolHashes.contains(hash))
.count();

detectedTxLastBlock = unseen;

if (unseen > 0) {
if (detectedTxTotal != null) {
detectedTxTotal.inc(unseen);
}
LOG.info(
"Block {} contained {} transaction(s) not seen in the mempool",
blockContext.getBlockHeader().getNumber(),
unseen);
}
}
}

9. Build the plugin

From the project root, run the distZip task:

./gradlew distZip

The build produces build/distributions/tx-detection-plugin.zip, which contains your plugin JAR. Because the plugin has no extra dependencies, the ZIP contains a single JAR.

Inspect the archive to confirm:

unzip -l build/distributions/tx-detection-plugin.zip

10. Deploy the plugin to Besu

Create a plugins directory at the root of your Besu installation if it doesn't already exist, then unzip the archive into it. The -j option flattens the ZIP so the JAR lands directly in plugins/:

mkdir -p /path/to/besu/plugins
unzip -j build/distributions/tx-detection-plugin.zip -d /path/to/besu/plugins/

If you installed Besu using Homebrew or docker, see Deploy a plugin.

11. Run Besu with the plugin

The plugin relies on mempool gossip and block import events, so it needs a node following a live network. This tutorial runs it on the Hoodi testnet, which syncs quickly and still surfaces transactions that never appeared in your node's public mempool.

Run Besu as an execution client on Hoodi alongside a consensus client. Start Besu with metrics enabled and the plugin's metric category included. The --metrics-category option replaces the default set of categories, so list the TX_DETECTION category to expose the plugin's metrics:

besu \
--network=hoodi \
--engine-rpc-enabled \
--engine-jwt-secret=<path to jwtsecret.hex> \
--engine-host-allowlist="*" \
--metrics-enabled=true \
--metrics-host=127.0.0.1 \
--metrics-port=9545 \
--metrics-category=TX_DETECTION

Then start your consensus client. For full setup instructions, including generating the shared JWT secret and starting the consensus client, see how to connect to a testnet or follow the Besu and Teku testnet tutorial.

12. Verify the plugin is running

Check the Besu startup logs to confirm the plugin was detected and started. You should see your start message:

Transaction detection plugin started

Once the node is synced and following the chain head, the plugin logs each block that contains transactions it never saw in the mempool:

Block 1234567 contained 8 transaction(s) not seen in the mempool

You can paste the block number into a block explorer to inspect the block and its transactions, and confirm whether any were submitted directly to a builder rather than the public mempool.

note

During initial sync, before your node receives mempool gossip, almost every block transaction appears unseen. Wait until the node is fully synced for accurate results.

13. Query the metrics

Besu exposes metrics in Prometheus format on the metrics port. Query the endpoint and filter for the plugin's category:

curl -s http://localhost:9545/metrics | grep tx_detection

You should see the plugin's counter and gauge, for example:

# HELP tx_detection_total_detected Total transactions imported in blocks but never seen in the mempool
# TYPE tx_detection_total_detected counter
tx_detection_total_detected 152.0
# HELP tx_detection_last_block Transactions in the most recent block never seen in the mempool
# TYPE tx_detection_last_block gauge
tx_detection_last_block 8.0
note

The exact Prometheus metric names and any suffixes depend on the Besu version and metrics backend. Use the tx_detection filter to find the current names in your output.

You now have a working Besu plugin that detects and reports potentially privately built transactions.

Next steps