How to Send deadbeef Transactions with Alloy

Published: December 11, 2024

Reading time: 8 minutes

Building the next generation of Rust performance tooling.

hotpath-rs profiles CPU, memory, SQL, async execution, channels and lock contention.

hotpath-rs Rust profiler mascot Ferris the crabExplore hotpath-rs →

0x0000000 accounts are so last season. Now it’s all about vanity tx prefixes. In this blog post, we will learn how to impress your Etherscan followers and fry a few CPU cores along the way.

Cow

Vanity txs

It all started from this X thread. Apparently, there’s a bigbrainchad.eth bot, that extracts the MEV in style. All his txs hashes start with the 0xbeef prefix:

bigbrainchad.eth txs prefix

I immediately got jealous and also wanted to send a few cool-looking txs. Hence the birth of alloy-deadbeef lib, and soon my vanity tx landed on the mainnet:

0xdeadbeef on etherscan

Read on to learn about the lib’s implementation details and performance overhead of sending such fancy txs.

How to brute-force ETH tx prefix?

You can use alloy-deadbeef as an alloy filler:

let provider = ProviderBuilder::new()
    .filler(DeadbeefFiller::new(
        "beef".to_string(),
        wallet.clone(),
    )
    .wallet(wallet)
    .on_http(endpoint().parse()?);

All the txs sent from this provider will land with a 0xbeef prefix.

Or generate a tx object that will commit to the chain with the desired prefix (unless you change any of its data!):

let tx = TransactionRequest {
    from: Some(account),
    to: Some(account.into()),
    ..Default::default()
};

let deadbeef = DeadbeefFiller::new(
    "beef".to_string(),
    wallet
);

let prefixed_tx = deadbeef.prefixed_tx(tx);

The lib brute forces a matching hash prefix, by incrementing the transaction gas_limit or value attribute. More details on that later.

We’re working with 16^n complexity here. Even Rust takes a while to crunch these numbers. To optimize performance, the lib is maxing out parallelism by spawning tokio task for each CPU available. The core loop looks like this:

src/lib.rs

let mut buf = Vec::with_capacity(200);

let result: Option<U256> = loop {
    select! {
        biased;
        _ = done.recv() => {
            break None;
        }
        _ = futures::future::ready(()) => {
            let tx = tx.clone();
            let next_value = tx.value.unwrap_or_default() + U256::from(value);
            let tx_hash = tx_hash_for_value(tx, &wallet, next_value, &mut buf).await?;
            value += 1;

            let hash_str = format!("{:x}", &tx_hash);
            if hash_str.starts_with(&prefix) {
                let iters = value - starting_input;
                let total_iters = max_cores * iters;
                info!("Found matching tx hash: {tx_hash} after ~{total_iters} iterations");
                break Some(next_value);
            }
        }
    }
};

async fn tx_hash_for_value(
    tx: TransactionRequest,
    wallet: &EthereumWallet,
    value: U256,
    buf: &mut Vec<u8>,
) -> Result<FixedBytes<32>> {
    let mut tx = tx;
    buf.clear();
    tx.value = Some(value);
    let tx_envelope = tx.build(&wallet).await?;
    tx_envelope.encode_2718(buf);
    let tx_hash = keccak256(&buf);
    Ok(tx_hash)
}

Tx hash is generated by running keccak256 on a signed transaction body, which is why we need to provide an EthereumWallet object. We use a select! macro to kill the remaining loops when the correct prefix is found.

Initially, I’ve used Arc<Mutex<bool>> like this:

  let is_done = done.lock().await;
  if *is_done {
      break None;
  }
  drop(is_done);

But multiple threads competing for the mutex lock on each loop did wreck the performance. It was ~6x slower than tokio CancellationToken:

Mutex vs CancellationToken performance

In the end, I’ve switched to tokio::sync::broadcast::channel. Contrary to CancellationToken, it does not require a separate crate, and is just as fast. You can measure channel throughput and message latency to validate this kind of switch.

Another interesting discovery was that tokio::time::sleep(Duration::from_millis(0)) has a delay of ~1ms. It killed the performance until I replaced it with futures::future::ready(()), which is instant.

Benchmarking tx prefix generation

I wanted to include a benchmark showing how long on avg. it takes to find a matching prefix. But I’ve found out it’s very random. 0xbeef can take anywhere between 200ms and 900ms, depending on the rest of the transaction attributes. Instead, I’ve AI-ed my way to finding how many iterations are needed to produce the desired prefix with 99% probability:

Length Probability (1 in) Iterations for ~99% certainty
1 1/16 ~72
2 1/256 ~1,180
3 1/4,096 ~18,900
4 1/65,536 ~302,000
5 1/1,048,576 ~4,830,000
6 1/16,777,216 ~77,900,000
7 1/268,435,456 ~1,240,000,000
8 1/4,294,967,296 ~19,800,000,000
9 1/68,719,476,736 ~316,000,000,000

An interesting takeaway is that increasing the gas limit by up to 300,000 should not cause any issues with landing the transaction on-chain. The current implementation of alloy-deadbeef iterates on gas_limit instead of value for prefixes up to 4 characters. It means that we can also flex for non-payable transactions:

Non-payable vanity tx

Alternatively, you can force a specific iteration mode like this:

let mut deadbeef = DeadbeefFiller::new("beef".to_string(), wallet)?;
deadbeef.set_iteration_mode(IterationMode::Value);

Let’s now analyze the maximum processing time. Assume we’re using a computer with 4 CPU cores and searching for 4-character prefix. Each loop has to process a maximum of 302,000 / 4 i.e. 75,500 iterations. So iteration starts from the following values for each of the loops:

  • 0 - 0
  • 1 - 75500
  • 2 - 151000
  • 3 - 256500

The table contains info on what’s the maximum processing time on my MBP M2 and beefed out 48 vCPUs Hetzner:

Length MBP M2 12 CPUs Hetzner VPS 48 vCPUs
1 547.58µs 397.69µs
2 5.81ms 3.18ms
3 123.86ms 45.38ms
4 1.82s 755.83ms
5 29.62s 12.08s
6 ~8 minutes* ~3 minutes
7 ~128 minutes* ~48 minutes*
8 ~34 hours* ~13 hours*
9 ~23 days* ~8 days*

* extrapolated

Hetzner VPS maxed out
Hetzner VPS maxed out crunching 0xdeadbeef

You can see that each additional prefix length increases processing time by ~16x, just as we’ve calculated. But these are the worst-case numbers.

deadbeef tx logs

My 0xdeadbeef tx in practice took ~1.5 hour and slightly over 2 billion iterations to generate. According to my AI friends, the probability of this happening was at ~37%.

Summary

Our research shows that it is possible to efficiently use up to 4 character-long prefixes. It could be a simple way to intimidate your MEV competition and assert alpha bot dominance. alloy-deadbeef was a fun weekend project. I’d love to learn how to optimize it further, so please send over any suggestions/PRs.

[Subscribe ]

Catch Rust performance regressions before they reach production.

hotpath-cloud turns profiling data into automated PR checks. Catch slower functions, new allocations, and extra SQL queries, with customizable regression thresholds and performance budgets.

See how hotpath-cloud works →