# Attested TLS in the Wild import { Accordion, Accordions } from 'fumadocs-ui/components/accordion'; # Introduction Trusted Execution Environments (TEEs) are a practical foundation for confidential computing: they protect code and memory at runtime, even when the surrounding host is not fully trusted. But distributed systems are remote by default, so the question becomes: how does a client verify that it's really talking to a TEE endpoint, that the channel terminates inside that TEE (no eavesdropping), and that the endpoint is running the expected software? Transport Layer Security (TLS) provides part of the answer: channel confidentiality and authenticity. A client can authenticate a server through a public key (Web PKI). However, TLS alone doesn't show that the key is usable only from inside the TEE. To get that guarantee, you need a way to prove what runtime generated that key, and to bind those runtime claims to the specific live session. This is where attested TLS (aTLS) comes in: bind remote attestation evidence to the live TLS session so peers can verify transport identity, TEE authenticity, and expected runtime state. }> Put differently, aTLS answers one question: does this session key belong to this measured software instance? In this post, we describe how we handled this in practice while migrating a network of TEEs from libp2p to a new transport. We first shipped a working TEE-to-TEE mutual attestation flow, then mapped it to the aTLS draft and reference implementation to understand what we matched, what we didn't, and what the extra mechanisms buy you under different threat models. In this article, when we refer to aTLS we are always talking about **Post-Handshake** Attested TLS, as that's the pattern we are using in our implementation. There are 2 more variants of aTLS currently in discussion within the IETF: **Pre-Handshake** and **Intra-Handshake,** both covered by the FOSDEM talk referenced below. # The Remote TEE Trust Problem For a remote peer, three properties must hold at the same time to extend TEE guarantees such as confidentiality and integrity to the network channel: 1. **Channel confidentiality and integrity.** Traffic must be unreadable and tamper-evident in transit, otherwise sensitive inputs and outputs can be leaked or modified. 2. **Endpoint authenticity and in-TEE termination.** The remote endpoint must be the intended TEE-backed peer, not a substituted endpoint or a man-in-the-middle. To guarantee this, encryption must terminate inside the TEE (i.e. plaintext only exists within the enclave's protected memory). 3. **Runtime integrity against policy.** The remote TEE must be running software state that matches policy; otherwise you may be talking to a genuine TEE running the wrong code. TLS gives you an encrypted channel and proof of key ownership; remote attestation gives you evidence about runtime state. aTLS is the standard way to make those proofs refer to the same live session. # aTLS at a High Level ## Quick (m)TLS Refresher TLS 1.3 starts with a handshake that negotiates fresh session keys and authenticates certificate key ownership. In mutual TLS, both peers present certificates and prove possession of their private keys by signing a challenge. This gives you an encrypted and authenticated channel, addressing the first 2 problems, although it doesn't really provide any assurances that you are talking to a TEE directly (second half of problem 2) or that you are indeed communicating to the *exact* TEE you are expecting (problem 3). ## What aTLS Adds The easiest way to reason about aTLS is as a composition of three independent proofs that must all hold at the same time. TLS proves channel confidentiality and peer key ownership. Attestation proves what code is running in the TEE. Binding proves that the attestation evidence belongs to this exact secure channel, not some other session. This security shape is the core idea behind aTLS. The specific message formats and primitives can vary, but the outcome should be the same: channel key ownership is cryptographically bound to attested software identity. With that model in place, we can now move from generic protocol properties to the concrete T+ deployment that motivated our implementation. # The T+ Confidential Exchange T+ ([@tplus\_cx](http://x.com/tplus_cx)) is a multi-node confidential exchange with different service roles, each running in its own TEE. In this setup, end-to-end attestation is a networking requirement: peers need confidential channels, proof that the remote endpoint is a genuine TEE peer, and proof that its running expected software, to ensure full trade privacy. In the T+ system, **critical communication flows are fully end-to-end attested, and thus confidential**. That requirement shaped our transport design, which we are describing next. ## The TEE-to-TEE Flow We Shipped After a mutual TLS handshake establishes an encrypted channel and verifies peer certificates, both peers run an attestation exchange to complete the custom handshake. Each side extracts the other peer's certificate public key from the active TLS session, asks the local TEE runtime for their own evidence bound to that session, and exchanges attestation payloads over the encrypted channel, verifying that the received payload is valid against the extracted peer's certificate public key and TEE runtime state. Since each node generates a self-signed peer certificate at startup, the transport identity is ephemeral and tied to the TEE's lifetime. Making use of this allows us to guarantee that the only entity in possession of the certificate and that could thus complete the handshake *must* be the intended TEE peer we just verified. In TDX (and similar TEE platforms), this attestation evidence is called a **quote**: a hardware-signed statement that includes measurements, including cryptographic digests of the code, configuration, and initial state loaded into the TEE. In addition, a quote also carries caller-supplied input data, which is how we bind it to TLS identity: the caller includes the TLS cert public key as input data. **This is how the channel is proven to be secure: the quote attests to the fact that the code and the runtime state is what we expect, and it also binds to the ephemerally generated and unique, self-signed certificate**. Here is the flow in diagram form: In our current deployment this is mutual attestation, so each side verifies the other before admitting protocol traffic. To keep the diagram readable, the sequence below shows one direction only. The reverse direction is symmetric. In production, we also bind a stable identity (for example the node operator) to the ephemeral TLS session so auditing and policy stay stable even as transport keys rotate. With that flow in place, the integration point became clear: the attestation check should run after transport setup, but before protocol traffic is accepted. ## Why ConnectionHooks Were the Right Migration Point [`msg-rs`](https://github.com/chainbound/msg-rs), our Rust messaging transport library, recently introduced connection hooks that run after transport setup and before protocol traffic. Connection hooks make it trivial to implement application-layer handshakes, which is exactly where attestation belongs. If you want more context on `msg-rs`, we covered it in previous entries such as [Linkem](https://engineering.chainbound.io/linkem) and [Introducing Flowproxy](https://engineering.chainbound.io/introducing-flowproxy). [`ConnectionHook`](https://docs.rs/msg-socket/latest/msg_socket/hooks/trait.ConnectionHook.html) was also the migration lever for us: it let us re-implement trust-boundary behavior that previously lived in fork-specific libp2p code as an explicit, reusable integration point in `msg-rs`. This preserved T+'s existing attestation requirement while keeping transport attestation out of business logic. If code is more your *vibe* than prose, here's a short example taken from the library: ```rust /// Runs after transport setup, before protocol traffic. /// Return Ok(io) to accept, Err to reject and close. trait ConnectionHook { type Error; async fn on_connection(&self, io: Io) -> HookResult; } ``` ```rust impl ConnectionHook for AtlsServerHook where Io: AsyncRead + AsyncWrite + Send + Unpin + 'static + TlsCerts, F: Fn(&Bytes) -> bool + Send + Sync + 'static, { type Error = ServerHookError; async fn on_connection(&self, io: Io) -> HookResult { // Obtain the peer certificate from the underlying transport let client_cert = io.peer_cert(); let mut conn = Framed::new(io, Codec::new_server()); // Wait for the client to send their attestation let msg = conn.next().await .ok_or(Error::hook(ServerHookError::ConnectionClosed))?; let Message::Attestation(payload) = msg? else { return Err(Error::hook(ServerHookError::ExpectedAuthMessage)); }; // Validate the client's attestation against the certificate used // for the TLS session if !self.validate_attestation(payload, client_cert) { conn.send(auth::Message::Reject).await?; return Err(Error::hook(ServerHookError::Rejected)); } conn.send(Message::Authenticated).await?; // remove Framed wrapper and return the, now attested, IO Ok(conn.into_inner()) } } ``` ```rust // Server: validate incoming attestations let pub_socket = PubSocket::new(Tcp::default()) .with_connection_hook(AtlsServerHook::new()); // Client: send attestation on connect let sub_socket = SubSocket::new(Tcp::default()) .with_connection_hook(AtlsClientHook::new()); ``` With this abstraction in place, we can attach an attestation hook to the sockets that need trust enforcement. If attestation fails, the connection is rejected immediately before the application can make use of it. # From Our Implementation to aTLS With that working baseline in place, we mapped it against the aTLS draft to see where it aligned, where it diverged, and why. The current IETF draft standardizes this binding in a portable, interoperable way. Like our flow, aTLS runs attestation after a normal TLS 1.3 handshake. The difference is in the machinery it uses, which rests on three building blocks: * **Exported Keying Material (EKM) ([RFC 5705](https://datatracker.ietf.org/doc/html/rfc5705))**: a value derived from the TLS master secret that is unique per session. Including it in the attestation evidence binds the produced quote to the *session.* * **Exported Authenticators ([RFC 9261](https://datatracker.ietf.org/doc/html/rfc9261))**: a standard message format that lets a TLS peer prove an additional identity after the handshake, reusing TLS's own structure. aTLS uses this as the envelope for attestation evidence. * **RATS Conceptual Message Wrapper ([draft](https://datatracker.ietf.org/doc/draft-ietf-rats-msg-wrap/))**: a uniform container for platform-specific attestation evidence (e.g. a TDX quote) with type metadata, so verifiers don't need custom parsing per platform. Useful references for aTLS itself: * [IETF draft](https://datatracker.ietf.org/doc/draft-fossati-seat-expat/) * [Reference implementation](https://github.com/tls-attestation/attestation-exported-authenticators) * [FOSDEM 2026 Talk](https://fosdem.org/2026/schedule/event/GHGFBM-attestedtls/) ## Cert-Pubkey Binding vs EKM in Controlled Deployments Comparing our implementation to the draft, one difference mattered most: the standard binds evidence to the session using EKM, while our version bound it to the TLS certificate public key. The rest of this section is about when that difference changes the security story. Our baseline intuition was cert-pubkey binding: generate the TLS keypair inside the TEE, put the cert pubkey hash into the attestation quote input, verify measurements and policy, and trust the session. **Is that enough, or do you need EKM?** In our case, that question is evaluated under explicit assumptions: a controlled peer set, startup-generated self-signed certs, and TEEs expected to run the same measured software. ### Why Cert-Pubkey Binding Can Be Sufficient For a controlled deployment where cert keys are generated inside the TEE and never leave it, the TLS handshake already proves key possession, and only the TEE can satisfy that proof. The quote then proves expected measurements, while quote input data ties that quote to the cert key used in TLS. Under this model, an outside attacker cannot replay a quote and complete the handshake without also possessing the key, so cert-pubkey binding is a sound baseline. ### Why aTLS Still Prefers EKM EKM adds value in broader or stricter models. If key bytes are exfiltrated through side channels, an attacker could complete TLS handshakes with stolen key material. EKM is unique per session, so captured attestation cannot be replayed across sessions even with the same cert key. Standards also need an interoperable channel binding mechanism that does not assume a TEE-generated cert lifecycle. So this is not either-or. EKM is a stronger and more general binding primitive. The practical question is when you need that extra strength. ## Threat-model Menu: Pick What You Need In practice, the mapping became a menu: pick a threat model, then pick the mechanism that addresses it. | Mechanism | What it protects against | When to use | Cost | | ------------------------------------- | --------------------------------------------------------------------- | ----------------------------------------------------------- | ----------- | | Quote input bound to TLS cert pubkey | Detached quote replay with unrelated cert/key | Baseline for TEE-generated cert deployments | Low | | Challenge nonce | Reusing old evidence without proving freshness of attestation request | If freshness must be explicit beyond handshake liveness | Low | | EKM binding (RFC 5705) | Session replay even under key exfiltration scenarios | High assurance, mixed trust, non-TEE-managed cert lifecycle | Low-medium | | Mutual attestation | One-sided trust in asymmetric topologies | Any topology where both peers need TEE guarantees | Medium | | CMW evidence wrapping | Platform lock-in and custom evidence formats | Multi-platform or multi-org deployments | Low | | Full RFC 9261 Exported Authenticators | Incompatibility with other implementations | Required for cross-implementation interoperability | Medium-high | A pragmatic rollout strategy is to start with cert-pubkey binding and a strict measurement policy, add a nonce when you need explicit freshness, then add EKM for defense-in-depth, and finally adopt RFC 9261 plus CMW when interoperability becomes a requirement. # Other Sightings ### Confer's Private Inference The pattern shows up outside TLS as well. Confer's [Private inference](https://confer.to/blog/2026/01/private-inference/) writeup explains how they can serve fully private AI inference. They use [Noise](https://noiseprotocol.org/) Pipes instead of TLS, but the core idea is the same: bind attestation evidence to the cryptographic channel that carries protocol traffic. Their use case is one-directional, where the client verifies the inference endpoint. Our deployment uses mutual attestation, where both peers verify each other. The attestation topology differs, but the security shape is the same: verify quote authenticity, bind evidence to the channel handshake keys, and validate measurements before trusting application data. ### Flashbots & MEV Another example is Flashbots' [attested-tls-proxy](https://github.com/flashbots/attested-tls-proxy), a reverse HTTP proxy that runs a post-handshake attestation exchange after a normal TLS 1.3 handshake, using ALPN (Application-Layer Protocol Negotiation) to signal that attestation is expected. Its binding event covers both the cert-pubkey and EKM rows of our threat-model menu! It does so by combining a hash of the TLS leaf certificate public key with TLS exporter material. Flashbots use aTLS extensively throughout their confidential MEV products to provide their users with verifiable privacy. They're also involved in developing the aTLS standard. }> aTLS is best seen as the TLS-specific standardization of that broader channel-binding pattern. # Conclusion aTLS solves a real problem: binding remote attestation to an encrypted channel. Our path was not spec-first. We were migrating T+ from a forked networking stack to `msg-rs` and had to preserve an existing TEE-to-TEE attestation requirement during that transition, so we implemented a practical hook-based solution first and then studied the standard in detail. That second step gave us a clear map of which guarantees we already had, which ones we did not, and why we might want each additional mechanism. }> The right question is not "Did we implement every RFC mechanism?". The right question is "Which attacks matter for our deployment, and which mechanism closes that gap?". That framing turns aTLS from an all-or-nothing standard into a practical design toolbox for secure systems engineering. # Blob Notaries: a distributed blob publishing design to scale DA import { Step, Steps } from 'fumadocs-ui/components/steps'; Or, how to separate blob *data dissemination* from *consensus* in Ethereum. **TL;DR:** This proposal stems from the recent discussions on sharding the blob mempool to reach the medium-term scaling goals set out by the Ethereum roadmap. It can be summarized as: * Delegating a subset of Ethereum nodes to attest to having seen specific blobs * Delaying DA checks until the next slot, to prevent DoS and allow more time for propagation * Replacing blob transaction sidecars in the EL mempool with lightweight DA certificates ## Introduction Ethereum’s DA scaling roadmap has been primarily concerned with scaling DA on the consensus layer through DAS ([PeerDAS](https://eips.ethereum.org/EIPS/eip-7594), Proto-Danksharding). The core idea here is that data is securely sharded and dispersed across the validator set, with some mechanisms to ensure that the data in question is truly available without having to download all of it. This will allow us to scale DA to potentially hundreds of blobs per block. But as shown in a [recent post](https://ethresear.ch/t/on-the-future-of-the-blob-mempool/22613) by Mike Neuder, the EL blob mempool (*blobpool*) has not been given the same attention. In fact, in the blobpool today, every validator still downloads the full type 3 transaction (including blobs) through a lazy-pull mechanism. This asymmetry, even if not a problem today, could definitely become a bottleneck in the future. Furthermore, scaling DA on the CL will eventually be bottlenecked by the uplink of the (local) block builder, as the proposer is required to upload the entire blob bundle. We’ll see later that our proposal for fixing the blobpool unlocks distributed blob publishing as well. ## Existing Proposals Many solutions have been proposed for the asymmetry pointed out above. Here is a brief overview of these, along with their limitations: * **Deprecate the blobpool**: remove the blobpool from the EL and rely on builders to offer private [blob inclusion endpoints](https://docs.titanbuilder.xyz/api/eth_sendblobs). The problems here are the following: * we *want* a public blobpool for censorship resistance (CR), because without them, type 3 transactions could never be part of inclusion lists (ILs). CR would overall be much worse. * builders become responsible for propagating *all the blobs* as fast as possible, or they risk their block not becoming canonical. The blobpool today also serves as a pre-proposal blob distribution mechanism. For local block builders, this might force them to not include blobs at all, because they can’t deal with the required upload bandwidth. More on that in [this post](https://ethresear.ch/t/is-data-available-in-the-el-mempool/22329). * **Vertically shard the blobpool**: we can just mirror the structure on the CL, and require blob senders to vertically shard their blob before propagating it. The main concern here is DoS: malicious participants can flood the network with invalid shards, i.e. shards that don’t belong to a valid, fee-paying transaction, or just incomplete data. Researchers have proposed solutions including a [data-driven approach](https://notes.ethereum.org/@dankrad/BkJMU8d0R#Vertically-sharded-mempool) (i.e. gate the ability to send blobs based on some heuristics to make sure that senders won’t spam the network) and a [market-driven one](https://ethresear.ch/t/on-the-future-of-the-blob-mempool/22613) (i.e. use an in-protocol auction to gate access to the blobpool). * **Horizontally shard the blobpool**: blob transactions are still broadcast in full, like today, but they are propagated in different mempools or *subnets* based on some predicate (like sender address or transaction hash). The main advantage here is that it’s simple, and DoS resistant. But it would still require (local) block builders to download and propagate all blobs in order to propose them, as Dankrad pointed out [here](https://notes.ethereum.org/@dankrad/BkJMU8d0R#Vertically-sharded-mempool). The [most recent proposal](https://ethresear.ch/t/on-the-future-of-the-blob-mempool/22613) builds on vertical sharding, but includes a market mechanism to determine who can write to the blobpool. This ensures that there’s always an upfront cost to publishing blobs that can only be recovered when the full blob is included, and thus remediates the DoS issues with the original proposal. The main downsides we see are the following: * As a blob publisher: * The delay between having a blob to include and when it’s actually included. * The execution gas cost of placing a bid. * As a protocol: * Running an auction on the L1 can introduce significant complexity * Using Ethereum blockspace and gas for auction tickets might not be very efficient ## Proposal: Blob Committee We propose to introduce a new committee (the *Blob Committee*) that is a random subset of the validator set with the following responsibilities: * Receiving type 3 transactions (carrying blobs) and validating them * Attesting to the availability of the blob data by producing *blob certificates* (BLS signatures over the blob commitments) * Sharding and publishing the columns on the CL eventually We call these committee members ***Blob Notaries***. Blob notaries are able to isolate initial blob and transaction validation to protect against network-wide DoS. Additionally, they can be relied on as a temporary DA oracle during block production, which can therefore remove download bottlenecks from the hot-path of proposal, both with local building and PBS. Finally, they can increase the throughput of blob dissemination by distributing the work between themselves, unlocking *distributed blob publishing*. Note that this committee could eventually evolve into a separate validator role, skewed towards higher bandwidth usage, in the spirit of [rainbow staking](https://ethresear.ch/t/unbundling-staking-towards-rainbow-staking/18683). ### New Blob Transaction Flows We will focus on the proposal happy path to avoid cluttering the article with complexity. However, some preliminary questions and answers can be found in Implementation Notes below. #### Step 1: Acquire Blob Quorum Certificates (QCs) * The blob sender gossips the type 3 transaction (including the blob sidecar) to all blob notaries for that slot. * The blob notaries will validate the transaction on the pending state, and respond with their signature over the blob commitment. We call this signature a *Blob Certificate*. * The blob sender aggregates these BLS signatures into a 2/3 majority, obtaining a blob *Quorum Certificate (QC)*. This step ensures that, between committee members, enough honest members custody the blob data and attest that the transaction is valid. The QC is a reflection of that. However, the existence of a QC does not guarantee DA at network-level, as the attesters haven’t voted yet. They can be thought of as a “credible signal”, but not guaranteed availability. #### Step 2: Broadcast to the EL Mempool * Once the QC is obtained, the blob sender can send the type 3 transaction envelope with QC on the EL mempool instead of the type 3 transaction with blob sidecar. * Regular nodes only need to propagate the transactions with lightweight certificates. * Validating QC requires some level of communication with the CL. The design space is quite large, but we haven’t found a convincing design yet. See the *Open Questions* section for more details. #### Step 3: Block Proposal and Attestation * The proposer includes type 3 transactions in its block as usual. However, instead of adding the blob bundle to the beacon block envelope, it will add the QCs of the blobs. This consists in a new field `blob_quorum_certificates` on the [`BeaconBlockBody`](https://github.com/ethereum/consensus-specs/blob/dev/specs/deneb/beacon-chain.md#beaconblockbody) container. * The beacon block envelope *does not contain the full blob bundle here anymore*. * When the attesters receive the new block, they won’t validate the availability of its QCs *yet*, but instead they will *only validate the execution of the block* (more on this later). * If the block receives enough valid votes in time, it will become canonical, as usual. #### Step 4: Blob Shard Propagation * Now that the attesters have voted and added the new block to their canonical chain, the blob notaries can start disseminating the blob data to the rest of the network via vertical shards. * Any node is able to immediately verify that these shards are valid, because they belong to blobs whose commitment were included in the chain’s head block, with valid QCs. This prevents the DoS vector identified in the *Existing Proposals* above. * Blob notaries have already signed the blobs in the canonical block, so they are fully incentivized to share the data before the *data availability attestation deadline.* #### Step 5: DA Attestation * The next slot starts, and attesters are asked to vote for two things: 1. Valid execution of the block proposed in that slot (block N) 2. Data availability of the blobs proposed in the previous slot (block N-1) * Logically these are conceived as two attestation events, but in practice we can think of the attestation as accommodating a new, more sophisticated fork-choice rule: * block `N` *execution* must be *valid*, AND block `N-1` *data* must be *available*. * This mechanism allows for a full-slot worth of time to propagate the blob shards: approximately from the previous slot’s attestation deadline to the current slot’s attestation deadline. ### Benefits of This Design #### 1. High DA Throughput Blob throughput in Ethereum is bottlenecked by the following factors: * validators (including solo-stakers) need to upload the entire blob bundle when proposing * after PeerDAS, proposers will still have limited time to propagate blob shards in the CL Our proposal tries to address these limitations with traditional scaling methodologies: * Horizontal scaling: by having many blob notaries instead of just one proposer, the network’s cumulative blob uplink can be orders of magnitude higher. * Vertical scaling: by designating a specific network role (*à la rainbow staking*) for blob notaries, we can extend their hardware/bandwidth requirements without compromising on the nice properties of a credibly decentralized attester set, such as censorship and collusion resistance. #### 2. No blob shard spam on the CL If CL nodes were to receive a shard that is not part of any recent blob, they can reject it and apply the necessary reputation penalties to the peer that shared it, minimizing the DoS potential. As a side benefit, delaying the DA checks to the next slot will also maximize the likelihood of data being available in time, because blob notaries now have a full slot (roughly from the previous slot’s attestation deadline to the current’s slot attestation deadline) to propagate shards. #### 3. Cheaper blob transaction replacements in the EL Currently, replacing a type 3 transaction is very expensive (e.g. [Geth requires a 100% fee bump](https://github.com/ethereum/go-ethereum/blob/b47e4d5b38b34c045cb10af6c0b5603c285310cd/core/txpool/blobpool/blobpool.go#L1142-L1179)). This is mainly because the network has to incur the cost of propagating the full blob sidecar in the EL mempool. With blob committees, transactions in the EL mempool would only carry lightweight QCs, making transaction replacements as cheap as other transaction types. #### 4. Lower “cost of latency” for including blobs Blob transactions in PBS today need to pay an indirect latency cost. This is because blocks with more blobs [need to compete with more lightweight blocks in the PBS auction](https://ethresear.ch/t/blobs-reorgs-and-the-role-of-mev-boost/19783). With this proposal, blocks with more blobs would only carry a marginal size increase for their certificate. #### 5. Trivial support for type 3 IL transactions Since the full blob is replaced by a lightweight DA certificate in the EL mempool, type 3 transactions become much smaller and can be supported by [FOCIL](https://eips.ethereum.org/EIPS/eip-7805). Validating the QC would be part of the [CL P2P validation rules](https://eips.ethereum.org/EIPS/eip-7805#cl-p2p-validation-rules) for validating ILs. ## Compatibility with ePBS In this section we discuss the compatibility with some of the slot restructuring proposal headliners for [Glamsterdam](https://forkcast.org/upgrade/glamsterdam). ### ePBS ([EIP-7732](https://eips.ethereum.org/EIPS/eip-7732)) The current ePBS spec makes it possible to shift the execution and DA attestations into the same slot, allowing for a much simpler fork-choice rule. However, since there is no natural "commit" phase for the QCs anymore, nodes must rely on weaker guarantees to counter DoS. Essentially, blob notaries would need to wait for the payload release before they can start propagating blob shards. This way, recipient nodes would be able to verify that the data they're receiving is actually part of the payload they just received from peers. Any shard not part of the payload would be discarded, and the sender would accrue negative reputation. To address data races, nodes could even wait for a payload before judging the validity of recently received shards. Here is the slot structure, following the recent [double deadline PTC vote](https://notes.ethereum.org/@anderselowsson/Dual-deadlinePTCvote) ePBS design: Blob shards received before the red X would be cached by attesters for a short while, and once the payload arrives, they would be validated. If they don’t match any of the blob commitments included in the payload, then they can be discarded and the sending peer penalized. If they are valid, then they can be broadcast to other peers. This way, regular attesters will only participate in fan-out once they know they are propagating valid data, which is desired. ## Open questions 1. The blob notaries will most likely require higher bandwidth than regular attesters. How should the network deal with low-performing notaries? Should they simply miss the rewards or get slashed? How does the network detect (poor) performance reliably? 2. Should blob notaries be rewarded fairly for their job in-protocol? If so, how? 3. How to verify QCs in the EL mempool? Any solution would require some communication of CL data to the EL; here are some possible options we’ve thought of: 1. We could store and update the blob notaries pubkeys in a system contract which gets regularly updated, and broadcast on the EL an aggregation bitlist in the new EIP-4844 variant along with the QCs (sort of similar to EIP-4788). 2. The existing engine API could be extended by sending tuples of `(tx_hash, quorum_certificates)` periodically via an endpoint `engine_newQuorumCertificates`. When a type 3 transaction announcement is received on devp2p, it is pulled only if the transaction hash has been already heard from the CL. 3. There is no validation of QCs in devp2p, but when the next proposer creates a block from EL data, in the `engine_forkchoiceUpdated` `PayloadAttributes` we also send a list of QCs already validated by the CL client. While this can still ensure proposal of valid transactions, it might open some DoS concerns in the EL mempool. 4. How would private blob submission to block builders work under this model? 1. Perhaps it would be possible to keep the current blob transaction pipeline as a fallback, which can then be used by builders to include blobs. Essentially, this means it would be possible to include a blob by either providing its entire contents in the proposal OR a valid signed QC. In case the beacon block envelope carries some full blobs, the block builder and proposer for that slot carry the extra risk of distributing the data in time for the DA attestation. This added risk could simply be absorbed by the PBS market by making private blobs more expensive to send on average, which also aligns with the desiderata of not straining the regular attesters with added bandwidth requirements. 5. Failure cases for attestation, missing DA, blob notary rewards, etc are still mostly TBD! ## Implementation Notes **On EIP-4844 transaction envelope and certificates** We introduce a new EIP-4844 transaction **variant** which carries a *quorum certificate* instead of the blob sidecar. The signed transaction envelope won’t change, as it already doesn’t include the sidecar today, but rather the QC will be treated as a new consensus layer item. Here is a short overview of how we imagine blobs could exist in the protocol: * With full blob sidecar: on the EL, at the RPC layer only (necessary for ingestion from users) * With a QC: in the EL mempool when the transaction can be included Note: we are not proposing to create a new transaction type. The EL spec already involves different representations of how EIP-4844 transactions can be seen: one with and one without the blob sidecar. We propose to add a new variant: without the blob sidecar but *with the QC*. **A malicious user might send type 3 transactions to the blob notaries, but then never submit the transactions in the mempool, wasting the resources of the blob notaries** * To avoid this, we can leverage the role of *aggregators* in the beacon chain to provide redundancy, so that any of them can submit the valid transaction in the EL mempool in place of the blob originator; this would look similar to the current attestation subnet aggregators, randomly selected between the committee members. * Blob aggregators perform their duty in step 1) of the proposed flow. In step 2\), aggregators can send the blob transaction in the mempool for added redundancy. **Why separate the two attestations (execution and DA)?** * The idea is that with this distinction the shards of blobs that are still pending wouldn’t be allowed to enter the CL P2P network, minimizing DoS vectors overall. * The added benefit is a *much* larger time frame for disseminating data, which scales well with throughput increases. ## Future Work * We plan to study compatibility with other EIPs currently planned for the [Glamsterdam](https://forkcast.org/upgrade/glamsterdam) hard-fork, such as [https://eips.ethereum.org/EIPS/eip-7886](https://eips.ethereum.org/EIPS/eip-7886), and six-second slot times as proposed in [https://eips.ethereum.org/EIPS/eip-7782](https://eips.ethereum.org/EIPS/eip-7782). * We’d like to come up with theoretical benchmarks for throughput allowed by this technique, based on existing p2p data and the new slot deadlines. * The requirements and economics of blob notaries are also an interesting topic that is out of scope of the technical spec but would be nice to explore. ## References * [https://ethresear.ch/t/on-the-future-of-the-blob-mempool/22613](https://ethresear.ch/t/on-the-future-of-the-blob-mempool/22613) * [https://ethresear.ch/t/payload-timeliness-committee-ptc-an-epbs-design/16054](https://ethresear.ch/t/payload-timeliness-committee-ptc-an-epbs-design/16054) * [https://ethresear.ch/t/estimating-validator-decentralization-using-p2p-data/19920#long-lived-subnets-node-metadata-9](https://ethresear.ch/t/estimating-validator-decentralization-using-p2p-data/19920#long-lived-subnets-node-metadata-9) * [https://ethresear.ch/t/decoupling-throughput-from-local-building/22004](https://ethresear.ch/t/decoupling-throughput-from-local-building/22004) * [https://www.paradigm.xyz/2023/04/mev-boost-ethereum-consensus](https://www.paradigm.xyz/2023/04/mev-boost-ethereum-consensus) * [https://eips.ethereum.org/EIPS/eip-7886](https://eips.ethereum.org/EIPS/eip-7886) * [https://notes.ethereum.org/@dankrad/BkJMU8d0R](https://notes.ethereum.org/@dankrad/BkJMU8d0R) # FlowProxy: Approaching Optimality import { Accordion, Accordions } from 'fumadocs-ui/components/accordion'; import { GlobalMap } from '@/components/diagrams/map'; FlowProxy V2 ## Background In [**Introducing FlowProxy**](https://collective.flashbots.net/t/introducing-flowproxy/5341), [Chainbound](https://chainbound.io/) collaborated with Flashbots to deprecate its [original orderflow proxy](https://github.com/flashbots/buildernet-orderflow-proxy) implementation in Go in favor of a new one built in Rust, created with the goal of reducing end-to-end latency, improving efficiency, and increasing observability in [BuilderNet](https://buildernet.org/)'s networking layer. This first collaboration aimed at bringing it to production-ready quality and ready for deployment, already observing good improvements in networking and processing latency. This second collaboration expanded on the *Next Steps* section outlined in the previous report, with success. It includes a quantitative analysis of order losses and latencies across BuilderNet, and extensive work to improve the transport layer to reduce both CPU and memory usage. We encountered a couple of non-obvious learnings that we outline below, and we hope this will be useful to the community. ## Network Analysis FlowProxy features a Clickhouse integration which makes possible to record *bundle receipts.* A bundle receipt consists of a short summary that includes the timestamp of when the bundle was sent and received by instances, along with its raw size in bytes (useful for tracking size → latency correlation). We used this data to better understand FlowProxy’s network performance in its entirety, and to discover what improvements to focus on. ### Bundle Loss We started with analyzing bundle loss between instances. The table below reports the percentage of bundle loss on individual links, during a small time range of congestion: | src | dst | lost | total | loss\_rate\_pct | | ---------------------- | ---------------------- | ---- | ------ | --------------- | | beaver\_eastus\_07 | nethermind\_we\_08 | 7857 | 35848 | 21.92% | | beaver\_eastus\_07 | flashbots\_we\_09 | 1969 | 96597 | 2.04% | | beaver\_eastus\_07 | beaver\_we\_08 | 1354 | 94000 | 1.44% | | beaver\_eastus\_07 | flashbots\_eastus\_10 | 2 | 140801 | 0.00% | | beaver\_eastus\_07 | nethermind\_eastus\_07 | 1 | 141534 | 0.00% | | nethermind\_eastus\_07 | flashbots\_eastus\_10 | 10 | 35315 | 0.03% | | nethermind\_eastus\_07 | beaver\_eastus\_07 | 10 | 35377 | 0.03% | | nethermind\_eastus\_07 | flashbots\_we\_09 | 6 | 34560 | 0.02% | | nethermind\_eastus\_07 | beaver\_we\_08 | 6 | 35216 | 0.02% | | nethermind\_eastus\_07 | nethermind\_we\_08 | 4 | 34490 | 0.01% | | flashbots\_eastus\_10 | beaver\_we\_08 | 4 | 34877 | 0.01% | | flashbots\_eastus\_10 | flashbots\_we\_09 | 4 | 34266 | 0.01% | | flashbots\_eastus\_10 | nethermind\_we\_08 | 3 | 34156 | 0.01% | | flashbots\_eastus\_10 | beaver\_eastus\_07 | 1 | 35054 | 0.00% | | src | dst | lost | total | loss rate | | ---------------------- | ---------------------- | ---- | ------ | --------- | | beaver\_eastus\_07 | nethermind\_we\_08 | 7857 | 35848 | 21.92% | | beaver\_eastus\_07 | flashbots\_we\_09 | 1969 | 96597 | 2.04% | | beaver\_eastus\_07 | beaver\_we\_08 | 1354 | 94000 | 1.44% | | nethermind\_eastus\_07 | flashbots\_mkosi\_1 | 58 | 31205 | 0.19% | | beaver\_eastus\_07 | flashbots\_mkosi\_1 | 13 | 125199 | 0.01% | | flashbots\_eastus\_10 | flashbots\_mkosi\_1 | 11 | 31415 | 0.04% | | nethermind\_eastus\_07 | flashbots\_eastus\_10 | 10 | 35315 | 0.03% | | nethermind\_eastus\_07 | beaver\_eastus\_07 | 10 | 35377 | 0.03% | | nethermind\_eastus\_07 | flashbots\_we\_09 | 6 | 34560 | 0.02% | | nethermind\_eastus\_07 | beaver\_we\_08 | 6 | 35216 | 0.02% | | flashbots\_eastus\_10 | beaver\_we\_08 | 4 | 34877 | 0.01% | | nethermind\_eastus\_07 | nethermind\_we\_08 | 4 | 34490 | 0.01% | | flashbots\_eastus\_10 | flashbots\_we\_09 | 4 | 34266 | 0.01% | | nethermind\_we\_08 | flashbots\_eastus\_10 | 3 | 113560 | 0.00% | | nethermind\_we\_08 | beaver\_eastus\_07 | 3 | 116296 | 0.00% | | nethermind\_we\_08 | nethermind\_eastus\_07 | 3 | 113862 | 0.00% | | flashbots\_eastus\_10 | nethermind\_we\_08 | 3 | 34156 | 0.01% | | beaver\_eastus\_07 | flashbots\_eastus\_10 | 2 | 140801 | 0.00% | | flashbots\_mkosi\_1 | beaver\_we\_08 | 1 | 95552 | 0.00% | | flashbots\_mkosi\_1 | nethermind\_eastus\_07 | 1 | 95172 | 0.00% | | nethermind\_we\_08 | beaver\_we\_08 | 1 | 104890 | 0.00% | | flashbots\_mkosi\_1 | beaver\_eastus\_07 | 1 | 96537 | 0.00% | | flashbots\_mkosi\_1 | flashbots\_we\_09 | 1 | 95015 | 0.00% | | flashbots\_eastus\_10 | beaver\_eastus\_07 | 1 | 35054 | 0.00% | | beaver\_eastus\_07 | nethermind\_eastus\_07 | 1 | 141534 | 0.00% | | flashbots\_mkosi\_1 | flashbots\_eastus\_10 | 1 | 93635 | 0.00% | | nethermind\_we\_08 | flashbots\_we\_09 | 1 | 103316 | 0.00% | Or visualized: Europe (northern route) { from: 'beaver_eastus', to: 'nethermind_we', metrics: { bundleLoss: 21.92 }, waypoints: [ [-42, 47], [-18, 49], ], }, { from: 'beaver_eastus', to: 'flashbots_we', metrics: { bundleLoss: 2.04 }, waypoints: [ [-40, 48], [-15, 50], ], }, { from: 'beaver_eastus', to: 'beaver_we', metrics: { bundleLoss: 1.44 }, waypoints: [ [-42, 44], [-18, 46], ], }, // US East local { from: 'beaver_eastus', to: 'flashbots_eastus', metrics: { bundleLoss: 0.0 }, }, { from: 'beaver_eastus', to: 'nethermind_eastus', metrics: { bundleLoss: 0.0 }, }, { from: 'nethermind_eastus', to: 'flashbots_eastus', metrics: { bundleLoss: 0.03 }, }, { from: 'nethermind_eastus', to: 'beaver_eastus', metrics: { bundleLoss: 0.03 }, }, // Transatlantic: Nethermind EUS -> Europe (middle routes) { from: 'nethermind_eastus', to: 'flashbots_we', metrics: { bundleLoss: 0.02 }, waypoints: [ [-38, 46], [-12, 48], ], }, { from: 'nethermind_eastus', to: 'beaver_we', metrics: { bundleLoss: 0.02 }, waypoints: [ [-35, 43], [-10, 45], ], }, { from: 'nethermind_eastus', to: 'nethermind_we', metrics: { bundleLoss: 0.01 }, waypoints: [ [-32, 50], [-8, 52], ], }, // Transatlantic: Flashbots EUS -> Europe (southern routes) { from: 'flashbots_eastus', to: 'beaver_we', metrics: { bundleLoss: 0.01 }, waypoints: [ [-30, 41], [-5, 44], ], }, { from: 'flashbots_eastus', to: 'flashbots_we', metrics: { bundleLoss: 0.01 }, waypoints: [ [-28, 44], [-3, 47], ], }, { from: 'flashbots_eastus', to: 'nethermind_we', metrics: { bundleLoss: 0.01 }, waypoints: [ [-25, 47], [0, 50], ], }, { from: 'flashbots_eastus', to: 'beaver_eastus', metrics: { bundleLoss: 0.0 }, }, // European interconnections { from: 'beaver_we', to: 'nethermind_we', metrics: { bundleLoss: 0.01 } }, { from: 'beaver_we', to: 'flashbots_we', metrics: { bundleLoss: 0.01 } }, { from: 'nethermind_we', to: 'flashbots_we', metrics: { bundleLoss: 0.01 }, }, ]} metricKey="bundleLoss" metricRange={[0, 25]} height={350} center={[-35, 45]} scale={350} showLegend={true} /> This map represents a simplified view of the BuilderNet topology, it's not geographically accurate. We can see in particular that inter-continental links are mostly affected by bundle loss, especially the instance `beaver_eastus_07` which receives the most flow in that region. The main reason bundles are marked as “lost” is when they time out, or when buffers overflow and backpressure is applied. This confirms what we [explored](https://collective.flashbots.net/t/introducing-flowproxy/5341#p-10727-http-connection-pools-11) in the last post: that HTTP/1.1 and its incapacity to multiplex requests makes it highly inadequate to deal with BuilderNet’s workload. ### Latency From the table below we can see latency between individual BuilderNet links during high congestion. | src | dst | p50\_ms | p99\_ms | corr\_payload\_size | observations | | ---------------------- | ---------------------- | ------- | -------- | ------------------- | ------------ | | beaver\_eastus\_07 | nethermind\_we\_08 | 39.619 | 1947.045 | -0.06 | 27991 | | beaver\_eastus\_07 | flashbots\_we\_09 | 38.022 | 1789.051 | -0.06 | 94628 | | beaver\_eastus\_07 | beaver\_we\_08 | 39.440 | 1521.599 | -0.04 | 92646 | | beaver\_eastus\_07 | nethermind\_eastus\_07 | 62.639 | 292.451 | -0.02 | 141533 | | nethermind\_eastus\_07 | beaver\_we\_08 | 33.381 | 194.660 | 0.59 | 35210 | | nethermind\_eastus\_07 | nethermind\_we\_08 | 31.541 | 193.107 | 0.58 | 34486 | | nethermind\_eastus\_07 | flashbots\_we\_09 | 30.992 | 192.134 | 0.60 | 34554 | | nethermind\_eastus\_07 | flashbots\_eastus\_10 | -0.662 | 58.648 | 0.06 | 35305 | | nethermind\_eastus\_07 | beaver\_eastus\_07 | -4.514 | 49.629 | 0.02 | 35367 | | flashbots\_eastus\_10 | beaver\_we\_08 | 40.197 | 201.535 | 0.60 | 34873 | | flashbots\_eastus\_10 | nethermind\_we\_08 | 38.467 | 200.267 | 0.59 | 34153 | | flashbots\_eastus\_10 | flashbots\_we\_09 | 37.945 | 199.814 | 0.61 | 34262 | | flashbots\_eastus\_10 | beaver\_eastus\_07 | 2.506 | 56.186 | 0.02 | 35053 | | flashbots\_eastus\_10 | nethermind\_eastus\_07 | -7.746 | 26.184 | 0.04 | 35048 | | src | dst | p50\_ms | p90\_ms | p99\_ms | p999\_ms | corr\_payload\_size | observations | | ---------------------- | ---------------------- | ------- | -------- | -------- | -------- | ------------------- | ------------ | | beaver\_eastus\_07 | nethermind\_we\_08 | 39.619 | 1668.253 | 1947.045 | 2005.370 | -0.06 | 27991 | | beaver\_eastus\_07 | flashbots\_we\_09 | 38.022 | 1414.608 | 1789.051 | 1978.707 | -0.06 | 94628 | | beaver\_eastus\_07 | beaver\_we\_08 | 39.440 | 852.713 | 1521.599 | 1970.474 | -0.04 | 92646 | | beaver\_eastus\_07 | flashbots\_mkosi\_1 | 39.591 | 419.213 | 1099.179 | 1221.009 | -0.03 | 125186 | | nethermind\_we\_08 | flashbots\_eastus\_10 | 44.564 | 126.509 | 294.049 | 502.033 | -0.21 | 113557 | | beaver\_eastus\_07 | nethermind\_eastus\_07 | 62.639 | 61.099 | 292.451 | 476.941 | -0.02 | 141533 | | flashbots\_mkosi\_1 | flashbots\_eastus\_10 | 43.261 | 84.517 | 281.969 | 560.069 | 0.16 | 93634 | | flashbots\_mkosi\_1 | beaver\_eastus\_07 | 44.489 | 66.412 | 257.931 | 519.216 | 0.24 | 96536 | | nethermind\_we\_08 | beaver\_eastus\_07 | 46.272 | 97.637 | 227.358 | 323.382 | 0.32 | 116293 | | nethermind\_we\_08 | nethermind\_eastus\_07 | 51.281 | 101.732 | 224.596 | 324.134 | 0.32 | 113859 | | nethermind\_we\_08 | beaver\_we\_08 | 25.860 | 5.459 | 216.067 | 324.788 | -0.02 | 104889 | | flashbots\_eastus\_10 | flashbots\_mkosi\_1 | 40.740 | 45.731 | 202.758 | 286.997 | 0.59 | 31404 | | flashbots\_mkosi\_1 | nethermind\_eastus\_07 | 49.122 | 61.092 | 202.012 | 273.676 | 0.37 | 95171 | | flashbots\_eastus\_10 | beaver\_we\_08 | 40.197 | 46.283 | 201.535 | 284.387 | 0.60 | 34873 | | flashbots\_eastus\_10 | nethermind\_we\_08 | 38.467 | 46.728 | 200.267 | 283.487 | 0.59 | 34153 | | flashbots\_eastus\_10 | flashbots\_we\_09 | 37.945 | 44.632 | 199.814 | 281.712 | 0.61 | 34262 | | nethermind\_eastus\_07 | flashbots\_mkosi\_1 | 33.733 | 37.802 | 195.461 | 204.237 | 0.59 | 31147 | | nethermind\_eastus\_07 | beaver\_we\_08 | 33.381 | 38.238 | 194.660 | 201.383 | 0.59 | 35210 | | nethermind\_eastus\_07 | nethermind\_we\_08 | 31.541 | 39.041 | 193.107 | 208.030 | 0.58 | 34486 | | nethermind\_eastus\_07 | flashbots\_we\_09 | 30.992 | 36.886 | 192.134 | 203.846 | 0.60 | 34554 | | flashbots\_we\_09 | flashbots\_eastus\_10 | 44.702 | 52.008 | 151.492 | 352.513 | 0.45 | 23666 | | nethermind\_we\_08 | flashbots\_mkosi\_1 | 3.024 | 5.885 | 149.235 | 237.358 | -0.02 | 128776 | | flashbots\_we\_09 | nethermind\_eastus\_07 | 51.533 | 53.694 | 135.308 | 214.133 | 0.65 | 23682 | | flashbots\_we\_09 | beaver\_eastus\_07 | 46.286 | 51.180 | 131.223 | 213.616 | 0.58 | 23676 | | beaver\_we\_08 | flashbots\_eastus\_10 | 42.202 | 45.092 | 124.000 | 320.903 | 0.46 | 21596 | | beaver\_we\_08 | beaver\_eastus\_07 | 43.786 | 46.276 | 110.435 | 222.164 | 0.54 | 21630 | | nethermind\_we\_08 | flashbots\_we\_09 | 0.300 | 4.165 | 88.781 | 154.436 | -0.01 | 105315 | | flashbots\_mkosi\_1 | nethermind\_we\_08 | -1.529 | 1.878 | 84.565 | 172.061 | -0.02 | 109297 | | beaver\_we\_08 | nethermind\_eastus\_07 | 49.078 | 50.298 | 62.551 | 215.596 | 0.82 | 21608 | | nethermind\_eastus\_07 | flashbots\_eastus\_10 | -0.662 | -2.668 | 58.648 | 220.645 | 0.06 | 35305 | | flashbots\_mkosi\_1 | beaver\_we\_08 | -0.075 | 2.209 | 57.603 | 115.534 | -0.02 | 95551 | | flashbots\_eastus\_10 | beaver\_eastus\_07 | 2.506 | 5.522 | 56.186 | 188.124 | 0.02 | 35053 | | flashbots\_mkosi\_1 | flashbots\_we\_09 | 2.188 | 9.008 | 52.778 | 91.397 | -0.00 | 95014 | | nethermind\_eastus\_07 | beaver\_eastus\_07 | -4.514 | -1.465 | 49.629 | 194.338 | 0.02 | 35367 | | flashbots\_eastus\_10 | nethermind\_eastus\_07 | -7.746 | -9.508 | 26.184 | 101.051 | 0.04 | 35048 | | flashbots\_we\_09 | nethermind\_we\_08 | -1.307 | 3.241 | 17.573 | 83.503 | 0.02 | 23585 | | beaver\_we\_08 | nethermind\_we\_08 | 1.076 | 3.533 | 14.321 | 98.753 | 0.03 | 21147 | | flashbots\_we\_09 | flashbots\_mkosi\_1 | 3.017 | 4.058 | 11.831 | 43.950 | 0.06 | 23651 | | beaver\_we\_08 | flashbots\_mkosi\_1 | 3.506 | 4.566 | 9.304 | 22.996 | 0.10 | 23289 | | beaver\_we\_08 | flashbots\_we\_09 | 1.137 | 1.804 | 8.416 | 25.661 | 0.10 | 20771 | | beaver\_we\_08 | flashbots\_we\_09 | -1.624 | -0.159 | 8.029 | 67.550 | 0.05 | 20979 | The negative p50 latencies here are due to [clock drift](https://en.wikipedia.org/wiki/Clock_drift). Or, with P99 latencies visualized: Europe (high latency routes) { from: 'beaver_eastus', to: 'nethermind_we', metrics: { latency: 1947.05 }, waypoints: [ [-42, 47], [-18, 49], ], }, { from: 'beaver_eastus', to: 'flashbots_we', metrics: { latency: 1789.05 }, waypoints: [ [-40, 48], [-15, 50], ], }, { from: 'beaver_eastus', to: 'beaver_we', metrics: { latency: 1521.6 }, waypoints: [ [-42, 44], [-18, 46], ], }, // US East local { from: 'beaver_eastus', to: 'nethermind_eastus', metrics: { latency: 292.45 }, }, { from: 'nethermind_eastus', to: 'flashbots_eastus', metrics: { latency: 58.65 }, }, { from: 'nethermind_eastus', to: 'beaver_eastus', metrics: { latency: 49.63 }, }, { from: 'flashbots_eastus', to: 'beaver_eastus', metrics: { latency: 56.19 }, }, { from: 'flashbots_eastus', to: 'nethermind_eastus', metrics: { latency: 26.18 }, }, // Transatlantic: Nethermind EUS -> Europe { from: 'nethermind_eastus', to: 'beaver_we', metrics: { latency: 194.66 }, waypoints: [ [-35, 43], [-10, 45], ], }, { from: 'nethermind_eastus', to: 'nethermind_we', metrics: { latency: 193.11 }, waypoints: [ [-32, 50], [-8, 52], ], }, { from: 'nethermind_eastus', to: 'flashbots_we', metrics: { latency: 192.13 }, waypoints: [ [-38, 46], [-12, 48], ], }, // Transatlantic: Flashbots EUS -> Europe { from: 'flashbots_eastus', to: 'beaver_we', metrics: { latency: 201.54 }, waypoints: [ [-30, 41], [-5, 44], ], }, { from: 'flashbots_eastus', to: 'nethermind_we', metrics: { latency: 200.27 }, waypoints: [ [-25, 47], [0, 50], ], }, { from: 'flashbots_eastus', to: 'flashbots_we', metrics: { latency: 199.81 }, waypoints: [ [-28, 44], [-3, 47], ], }, ]} metricKey="latency" metricRange={[0, 2000]} height={350} center={[-35, 45]} scale={350} showLegend={true} /> As with the previous table, we can see the highest latency between the most active inter-region links, peaking at nearly 2s in p99. Another interesting result is the correlation with payload size (`corr_payload_size`): in normal working conditions (low p99s), it is more pronounced. This could mean a couple of things that will be useful to know later: * We’re not able to send large payloads in a single transmission (related to [BDP](https://en.wikipedia.org/wiki/Bandwidth-delay_product), which we’ll talk about below). * Preparing the order for transmission, or processing the order on the receiver side, takes a noticeably longer time the bigger the message. Some correlation is expected here, but it should be minimal. The main processing steps in the hot path here are JSON encoding / decoding, and signing / signature verification. ## Improvements ### Thread modelling FlowProxy runs with the [Tokio](https://docs.rs/tokio/latest/tokio/) asynchronous runtime. The initial implementation of the proxy indiscriminately used Tokio tasks for all different kind of workloads, including CPU intensive operations like signature recovery, signing and decoding transactions. This approach is not ideal because the runtime and its tasks are fundamentally optimized for non-blocking, I/O-bound work, and using it for other [blocking or CPU-bound work](https://docs.rs/tokio/latest/tokio/index.html#cpu-bound-tasks-and-blocking-code) *will* increase tail latencies. Tokio schedules many lightweight tasks onto a small number of OS threads. If a task performs CPU-heavy or blocking work, it can monopolise a worker thread, preventing other tasks from making progress. We suspected this could partly be causing some of the high tail latencies we were seeing. The Tokio authors recommend using [`tokio::task::spawn_blocking`](https://docs.rs/tokio/latest/tokio/task/fn.spawn_blocking.html), which we tried initially. This will spawn (or reuse) a thread managed by the runtime that is purely used for CPU-bound and blocking operations. However, this resulted in many more threads being spawned than we knew was necessary, and also had more overhead than expected. We didn’t dive into this too much, but intuitively it seemed like the blocking thread scheduler was not reusing threads effectively ([Github issue](https://github.com/BuilderNet/FlowProxy/pull/141)). To mitigate this, we introduced a configurable pool of specialised [rayon](https://docs.rs/rayon/latest/rayon/) threads for compute-heavy operations, and tweaked the number of Tokio worker threads to match production environment requirements. This setup allows also to have a healthy environment where we can control how much resources a certain service is using, since the same machine would also run other processes like the block builder. **Side Note** While experimenting with the parameters, we observed how moving *some* specific compute-heavy operations resulted in a worse order processing latency. An interesting learning was that on a busy machine with many threads, sending tasks off to a different thread than the currently executing one *can* increase latency significantly. The cost of scheduling a new thread (context switching, waiting) should be taken into account, and is very context dependent! ### HTTP/2 HTTP/2 was designed to address various performance limitations of HTTP/1.1 while keeping the same semantics. Among various improvements, the most impactful for FlowProxy is **multiplexing**: with HTTP/1.1 only one request/response can be in flight per TCP connection (called *head-of-line blocking),* while HTTP/2 allows multiplexing multiple requests and responses over a single TCP connection, using *streams*. Streams are logical, bidirectional channels within one connection. They’re managed by *windows*: credit-based flow control mechanisms to limit how much data can be sent on a single stream, to ensure it doesn’t starve the connection. This multiplexing allowed us to greatly reduce the number of open connections, and improve connection reuse, which we already hinted was a source of message loss. Upgrading to HTTP/2 was the first improvement we rolled out, because of its complete backwards compatibility: communication between and towards instances running on a previous version of FlowProxy would simply fallback to HTTP/1.1. Below, you can see how the number of failures (read: lost messages) have been essentially reduced to 0 after its deployment. HTTP failures after deployment of HTTP/2 HTTP failures after deployment of HTTP/2 While request failures dropped, latency didn’t significantly improve. In particular, we’ve observed some improvement over small requests (with body size less than 32KiB) over inter-regional links, as we can see below. However, for same-region requests and bigger messages the situation remained identical or slightly worsened. RPC call duration latency (p99) before and after the deployment of HTTP/2. RPC call duration latency (p99) before and after the deployment of HTTP/2. This was a very different result compared to our staging environment, consisting of four nodes distributed between East US and West Europe. We think the main culprit is an overall different topology and network load compared to the production environment, which would be hard to completely emulate. After this result, we started looking into tuning configurations. FlowProxy instances operate with a reverse [HAProxy](https://www.haproxy.org/) that sits before the user and system endpoint, with the latter used for internal orderflow sharing. The proxy exposes some [HTTP/2 tuning configurations](https://docs.haproxy.org/3.2/configuration.html#tune.h2.be.initial-window-size:~:text=%2D%20tune.h2,copy%2Dfwd%2Dsend) that could help further reducing latency and spikiness. The dimensions in which we could operate were: * The number of maximum open streams; * The size of the window buffers; * Creating dedicated clients for small and big requests. While tuning those resulted in marginal improvements, we were still working on high-level abstractions, without much control over the metal. Moreover, HTTP/2 windows play a similar role to TCP congestion control / window scaling, resulting in some overhead and confusion about how the two interoperate. Because of this, we decided to pause HTTP/2 tuning efforts, and focus on a full migration to raw TCP (with TLS) with the [msg-rs](https://github.com/chainbound/msg-rs) messaging library. **Bundle loss after HTTP/2** After the deployment of this improvement, we analyzed bundle loss once again (read a full analysis [**here**](https://www.notion.so/PUBLIC-FlowProxy-Bundle-Receipts-Analysis-v2-2a45abfafc1980a29bb9fda91b3dd16d?pvs=21)). The table below contains a day worth of data, that includes both periods of low activity and high activity. We can see that bundle loss has essentially disappeared. Sample for 2025-11-14: | src | dst | lost | total | | ---------------------- | ---------------------- | ---- | -------- | | nethermind\_eastus\_07 | beaver\_we\_08 | 28 | 8894080 | | beaver\_eastus\_07 | flashbots\_eastus\_10 | 8 | 3001624 | | beaver\_eastus\_07 | flashbots\_we\_09 | 8 | 11168780 | | beaver\_eastus\_07 | nethermind\_eastus\_07 | 8 | 30111251 | | beaver\_eastus\_07 | beaver\_we\_08 | 8 | 10864297 | | beaver\_eastus\_07 | nethermind\_we\_08 | 8 | 10069603 | | nethermind\_eastus\_07 | flashbots\_we\_09 | 10 | 9028245 | | nethermind\_eastus\_07 | nethermind\_we\_08 | 8 | 9052880 | | nethermind\_eastus\_07 | beaver\_eastus\_07 | 6 | 10309013 | | nethermind\_eastus\_07 | flashbots\_eastus\_10 | 5 | 10302474 | | flashbots\_eastus\_10 | beaver\_we\_08 | 14 | 9024652 | | flashbots\_eastus\_10 | nethermind\_eastus\_07 | 6 | 10443797 | | flashbots\_eastus\_10 | beaver\_eastus\_07 | 6 | 10458639 | | flashbots\_eastus\_10 | nethermind\_we\_08 | 4 | 9087691 | | flashbots\_eastus\_10 | flashbots\_we\_09 | 2 | 9115212 | | src | dst | count | total | loss pctg | | --------------------------------------------- | --------------------------------------------- | ----- | -------- | --------- | | flashbots\_test\_1 | beaver\_eastus\_07 | 47 | 5297796 | 0% | | buildernet\_nethermind\_azure\_eastus\_07 | buildernet\_beaver\_azure\_westeurope\_08 | 28 | 8894080 | 0% | | buildernet\_beaver\_azure\_westeurope\_08 | buildernet\_beaver\_azure\_eastus\_07 | 24 | 5565633 | 0% | | buildernet\_flashbots\_azure\_eastus\_10 | buildernet\_flashbots\_mkosi\_test\_1 | 18 | 9283988 | 0% | | buildernet\_beaver\_azure\_westeurope\_08 | buildernet\_nethermind\_azure\_eastus\_07 | 16 | 5547538 | 0% | | buildernet\_beaver\_azure\_eastus\_07 | buildernet\_flashbots\_mkosi\_test\_1 | 16 | 10189003 | 0% | | buildernet\_flashbots\_azure\_eastus\_10 | buildernet\_beaver\_azure\_westeurope\_08 | 14 | 9024652 | 0% | | buildernet\_beaver\_azure\_westeurope\_08 | buildernet\_flashbots\_azure\_eastus\_10 | 10 | 5578403 | 0% | | buildernet\_nethermind\_azure\_eastus\_07 | buildernet\_flashbots\_azure\_westeurope\_09 | 10 | 9028245 | 0% | | buildernet\_flashbots\_mkosi\_test\_1 | buildernet\_flashbots\_azure\_eastus\_10 | 9 | 6146894 | 0% | | buildernet\_flashbots\_mkosi\_test\_1 | buildernet\_nethermind\_azure\_eastus\_07 | 9 | 6222701 | 0% | | buildernet\_nethermind\_azure\_eastus\_07 | buildernet\_nethermind\_azure\_westeurope\_08 | 8 | 9052880 | 0% | | buildernet\_beaver\_azure\_eastus\_07 | buildernet\_flashbots\_azure\_eastus\_10 | 8 | 3001624 | 0% | | buildernet\_beaver\_azure\_eastus\_07 | buildernet\_flashbots\_azure\_westeurope\_09 | 8 | 11168780 | 0% | | buildernet\_beaver\_azure\_eastus\_07 | buildernet\_nethermind\_azure\_eastus\_07 | 8 | 30111251 | 0% | | buildernet\_beaver\_azure\_eastus\_07 | buildernet\_beaver\_azure\_westeurope\_08 | 8 | 10864297 | 0% | | buildernet\_beaver\_azure\_eastus\_07 | buildernet\_nethermind\_azure\_westeurope\_08 | 8 | 10069603 | 0% | | buildernet\_nethermind\_azure\_westeurope\_08 | buildernet\_flashbots\_azure\_eastus\_10 | 7 | 6573168 | 0% | | buildernet\_flashbots\_azure\_westeurope\_09 | buildernet\_beaver\_azure\_eastus\_07 | 7 | 6078754 | 0% | | buildernet\_nethermind\_azure\_eastus\_07 | buildernet\_beaver\_azure\_eastus\_07 | 6 | 10309013 | 0% | | buildernet\_flashbots\_azure\_eastus\_10 | buildernet\_nethermind\_azure\_eastus\_07 | 6 | 10443797 | 0% | | buildernet\_nethermind\_azure\_eastus\_07 | buildernet\_flashbots\_mkosi\_test\_1 | 6 | 8967508 | 0% | | buildernet\_flashbots\_azure\_eastus\_10 | buildernet\_beaver\_azure\_eastus\_07 | 6 | 10458639 | 0% | | buildernet\_nethermind\_azure\_westeurope\_08 | buildernet\_nethermind\_azure\_eastus\_07 | 5 | 6513729 | 0% | | buildernet\_nethermind\_azure\_westeurope\_08 | buildernet\_beaver\_azure\_eastus\_07 | 5 | 6194898 | 0% | | buildernet\_nethermind\_azure\_eastus\_07 | buildernet\_flashbots\_azure\_eastus\_10 | 5 | 10302474 | 0% | | buildernet\_flashbots\_azure\_eastus\_10 | buildernet\_nethermind\_azure\_westeurope\_08 | 4 | 9087691 | 0% | | buildernet\_flashbots\_azure\_westeurope\_09 | buildernet\_flashbots\_azure\_eastus\_10 | 3 | 6117425 | 0% | | buildernet\_flashbots\_azure\_eastus\_10 | buildernet\_flashbots\_azure\_westeurope\_09 | 2 | 9115212 | 0% | | buildernet\_flashbots\_azure\_westeurope\_09 | buildernet\_flashbots\_mkosi\_test\_1 | 1 | 7617802 | 0% | | buildernet\_flashbots\_mkosi\_test\_1 | buildernet\_beaver\_azure\_westeurope\_08 | 1 | 18836193 | 0% | | buildernet\_flashbots\_mkosi\_test\_1 | buildernet\_flashbots\_azure\_westeurope\_09 | 1 | 18043957 | 0% | | buildernet\_nethermind\_azure\_westeurope\_08 | buildernet\_flashbots\_mkosi\_test\_1 | 1 | 9123905 | 0% | | buildernet\_nethermind\_azure\_westeurope\_08 | buildernet\_beaver\_azure\_westeurope\_08 | 1 | 13247037 | 0% | | buildernet\_flashbots\_azure\_westeurope\_09 | buildernet\_nethermind\_azure\_eastus\_07 | 1 | 6076706 | 0% | | buildernet\_flashbots\_mkosi\_test\_1 | buildernet\_nethermind\_azure\_westeurope\_08 | 1 | 7608331 | 0% | **HTTP/2 latency** Below we can see a more complete before-after of latency between links. We can see that after HTTP/2 we see more bounded p99 and p999, while p50 and p90 stayed almost the same. While picking a single day from both deployments may not be completely indicative, the behaviour remained quite consistent during the next days. Latency comparison of P99 latencies befor, each with 24 hours worth of datae and after HTTP/2 for a day's worth of data (all numbers in milliseconds): | src | dst | p50\_a | p50\_b | Δp50 | p99\_a | p99\_b | Δp99 | | ---------------------- | --------------------- | ------ | ------ | ---- | ------ | ------ | ---- | | nethermind\_we\_08 | beaver\_eastus\_07 | 42 | 42 | \~0 | 458 | 155 | -303 | | nethermind\_we\_08 | flashbots\_eastus\_10 | 42 | 41 | -1 | 455 | 142 | -313 | | beaver\_eastus\_07 | nethermind\_we\_08 | 44 | 41 | -3 | 357 | 125 | -232 | | beaver\_eastus\_07 | flashbots\_we\_09 | 45 | 46 | +1 | 297 | 122 | -175 | | nethermind\_eastus\_07 | flashbots\_we\_09 | 48 | 45 | -3 | 299 | 120 | -179 | | flashbots\_eastus\_10 | nethermind\_we\_08 | 42 | 43 | +1 | 206 | 119 | -87 | | flashbots\_eastus\_10 | flashbots\_we\_09 | 43 | 44 | +1 | 206 | 106 | -100 | | flashbots\_eastus\_10 | beaver\_eastus\_07 | 43 | 44 | +1 | 284 | 131 | -153 | | nethermind\_eastus\_07 | beaver\_eastus\_07 | 39 | 41 | +2 | 200 | 96 | -104 | | beaver\_eastus\_07 | flashbots\_eastus\_10 | 40 | 41 | +1 | 197 | 103 | -94 | ### TCP + `bitcode` To preserve backwards compatibility, we offered a new TCP-only version of system API along with the existing HTTP/2 one. Using a new temporary endpoint allowed us to experiment with binary encoding format, so we could finally remove JSON which other than offering high decoding latency, resulted in \~50% bigger payloads than necessary due to encoding hex data as strings. First, we tried to look into `serde` compatible binary encoding formats, like `bincode` and MessagePack, but both of them do not have full feature compatibility with `serde_derive`. Because of that, we opted out from `serde` compatibility and went with [`bitcode`](https://github.com/SoftbearStudios/bitcode) instead, which currently sits at the top of the [Rust Serialization Benchmark](https://david.kolo.ski/rust_serialization_benchmark/) leaderboard. This was the result of both a switch to TCP (without any additional tuning yet), and migration from JSON to bitcode: image.png However, there’s a big caveat here: we only saw these levels of improvements on very specific links. The plot above depicts latency from a specifically broadcast-heavy instance: one that ingests and forwards a lot of orders. We barely saw any improvements on other instances, which led us down the rabbit hole of TCP kernel parameters. ### Kernel Parameter Tuning In order to get the most of raw TCP sockets, it has been fundamental to tune kernel parameters to achieve optimised connections. To understand better the changes we’ve done, let’s brush up on some preliminaries. The *bandwidth-delay product*, or BDP, is a property of a network path, and is computed as the product of bandwidth, expressed in bytes per second, and round-trip time (delay), expressed in seconds. In the context of TCP, it represents how much in-flight data the connection can hold before the sender must wait for acknowledgments from the receiver. Let’s make a concrete example: a node in FlowProxy has a upload bandwidth of 1 Gbps, and an Azure link between East US and West Europe is around 85ms ([source](https://learn.microsoft.com/en-us/azure/networking/azure-network-latency?tabs=Americas%2CEastUS)). This results in a BDP of `1 Gbps × 85 ms = 85 Mbit = 10.625 MB`, meaning that is the theoretical maximum of data we can have unacknowledged in TCP, assuming ideal network conditions. Related to BDP is the *congestion window* (referred as `cwnd` in code). It is a core TCP mechanism that controls how much data a sender is allowed to have “in flight” (sent, but not yet acknowledged) at any given time. On the receiver side, there’s `rwnd` (the receive window), which determines how much unacknowledged data can accumulate at the receiver side before packets are dropped. The minimum of `cwnd` and `rwnd` determines your maximum throughput. One direct consequence of BDP is the following: if `min(cwnd, rwnd) < size(message)`, that message will need an **additional round trip to fully transmit!** By definition, the theoretical maximum size of the congestion window for communicating over a link matches the BDP. That would mean there is no congestion at all! However, the network might not be always stable, and packet loss might happen and RTT may vary over time. As such, the congestion window dynamically adapts to the appropriate amount of unacknowledged data to not waste any resources. These mechanisms are called *TCP congestion control algorithms.* All of these parameters can be modified in the Linux kernel with `sysctl`. The following are the most important: * `net.ipv4.tcp_congestion_control`: the default (`cubic`) worked for us. * `net.ipv4.tcp_window_scaling`: ensure this is turned on. * `net.ipv4.tcp_rmem`: sets the bounds for `rwnd`. Default and max had to be significantly increased, some multiple of your expected max message size is a good guideline. Linux will take care of autoscaling this if window scaling is turned on. Other than congestion algorithms, there are other settings that may impact the congestion window. One of them is called “slow start after idle”, which `sysctl` setting is `net.ipv4.tcp_slow_start_after_idle` . A connection is considered idle if a packet hasn’t been sent for a certain amount of time, computed as a function of the RTT, but with a default minimum of 200ms that we would be used in case of a 85ms RTT. In case of idleness, the congestion window is greatly reduced, diminishing throughput and increasing latency. Given connection between FlowProxy instances are long lived, and can be bursty in period of high-traffic, this setting should be disabled. In the picture below you can see the tremendous impact of the switch on low volume instances: Call duration latency (p99) before and after the net.ipv4.tcp_slow_start_after_idle has been disabled. Call duration latency (p99) before and after the `net.ipv4.tcp_slow_start_after_idle` has been disabled. This setting was exactly why the high message volume instances displayed really good latency after the switch to TCP, and the others didn’t: their TCP connections were never idle, so the congestion windows were never reset. The general takeaway here is that Linux TCP settings are very conservative and always try to save on resources, and should almost always be changed depending on your workload. ### Latency Comparison After all these modifications, we were finally approaching optimality in terms of order propagation: variance for big and small messages, over long and short links was reduced significantly. Check out the full comparison of P99 latencies below, each with 24 hours worth of data: **Before** Europe (high latency routes) { from: 'beaver_eastus', to: 'nethermind_we', metrics: { latency: 1947.05 }, waypoints: [ [-42, 47], [-18, 49], ], }, { from: 'beaver_eastus', to: 'flashbots_we', metrics: { latency: 1789.05 }, waypoints: [ [-40, 48], [-15, 50], ], }, { from: 'beaver_eastus', to: 'beaver_we', metrics: { latency: 1521.6 }, waypoints: [ [-42, 44], [-18, 46], ], }, // US East local { from: 'beaver_eastus', to: 'nethermind_eastus', metrics: { latency: 292.45 }, }, { from: 'nethermind_eastus', to: 'flashbots_eastus', metrics: { latency: 58.65 }, }, { from: 'nethermind_eastus', to: 'beaver_eastus', metrics: { latency: 49.63 }, }, { from: 'flashbots_eastus', to: 'beaver_eastus', metrics: { latency: 56.19 }, }, { from: 'flashbots_eastus', to: 'nethermind_eastus', metrics: { latency: 26.18 }, }, // Transatlantic: Nethermind EUS -> Europe { from: 'nethermind_eastus', to: 'beaver_we', metrics: { latency: 194.66 }, waypoints: [ [-35, 43], [-10, 45], ], }, { from: 'nethermind_eastus', to: 'nethermind_we', metrics: { latency: 193.11 }, waypoints: [ [-32, 50], [-8, 52], ], }, { from: 'nethermind_eastus', to: 'flashbots_we', metrics: { latency: 192.13 }, waypoints: [ [-38, 46], [-12, 48], ], }, // Transatlantic: Flashbots EUS -> Europe { from: 'flashbots_eastus', to: 'beaver_we', metrics: { latency: 201.54 }, waypoints: [ [-30, 41], [-5, 44], ], }, { from: 'flashbots_eastus', to: 'nethermind_we', metrics: { latency: 200.27 }, waypoints: [ [-25, 47], [0, 50], ], }, { from: 'flashbots_eastus', to: 'flashbots_we', metrics: { latency: 199.81 }, waypoints: [ [-28, 44], [-3, 47], ], }, ]} metricKey="latency" metricRange={[0, 2000]} height={350} center={[-35, 45]} scale={350} showLegend={true} /> The maximum P99 here is ~1950ms. **After** Europe { from: 'beaver_eastus', to: 'nethermind_we', metrics: { latency: 50.06 }, waypoints: [ [-42, 47], [-18, 49], ], }, { from: 'beaver_eastus', to: 'flashbots_we', metrics: { latency: 43.51 }, waypoints: [ [-40, 48], [-15, 50], ], }, { from: 'beaver_eastus', to: 'beaver_we', metrics: { latency: 43.82 }, waypoints: [ [-42, 44], [-18, 46], ], }, // US East local { from: 'beaver_eastus', to: 'nethermind_eastus', metrics: { latency: 7.55 }, }, { from: 'nethermind_eastus', to: 'flashbots_eastus', metrics: { latency: 9.0 }, }, { from: 'nethermind_eastus', to: 'beaver_eastus', metrics: { latency: 7.55 }, }, { from: 'flashbots_eastus', to: 'beaver_eastus', metrics: { latency: 4.4 }, }, { from: 'flashbots_eastus', to: 'nethermind_eastus', metrics: { latency: 2.25 }, }, // Transatlantic: Nethermind EUS -> Europe { from: 'nethermind_eastus', to: 'beaver_we', metrics: { latency: 47.88 }, waypoints: [ [-35, 43], [-10, 45], ], }, { from: 'nethermind_eastus', to: 'nethermind_we', metrics: { latency: 46.47 }, waypoints: [ [-32, 50], [-8, 52], ], }, { from: 'nethermind_eastus', to: 'flashbots_we', metrics: { latency: 48.87 }, waypoints: [ [-38, 46], [-12, 48], ], }, // Transatlantic: Flashbots EUS -> Europe { from: 'flashbots_eastus', to: 'beaver_we', metrics: { latency: 42.32 }, waypoints: [ [-30, 41], [-5, 44], ], }, { from: 'flashbots_eastus', to: 'nethermind_we', metrics: { latency: 45.89 }, waypoints: [ [-25, 47], [0, 50], ], }, { from: 'flashbots_eastus', to: 'flashbots_we', metrics: { latency: 44.28 }, waypoints: [ [-28, 44], [-3, 47], ], }, ]} metricKey="latency" metricRange={[0, 2000]} height={350} center={[-35, 45]} scale={350} showLegend={true} /> The maximum P99 here is ~50ms. There was one more change that we knew would be impactful, not on latency but on CPU usage. ### mTLS The last improvement we’ve been working on during this collaboration has been [mutual TLS](https://www.cloudflare.com/learning/access-management/what-is-mutual-tls/) (mTLS). If required by the server, the client must present a valid X509 TLS certificate to authenticate itself. This works, because all instances know each other's TLS certificates through a central service registry. Currently, FlowProxy authenticates other instances on a message-by-message basis: every message it gets needs to be signed by a known ECDSA public key. This is secure, but wasteful: every message needs to be a) signed by the sender, and b) verified by the receiver. mTLS would change authentication on a message basis, to authentication on a connection basis. Authentication would occur during connection setup, and after a successful authentication, all messages sent on that connection are transitively authenticated too. As of December 22, 2025, this feature hasn’t been deployed yet on a production environment, but from the picture below we can already appreciate its effects on the staging environment. Global CPU usage and thread usage after deployment of mTLS on a staging environment. Global CPU usage and thread usage after deployment of mTLS on a staging environment. From this preliminary result we can see a 50% reduction of CPU and thread usage, lowering total CPU usage of FlowProxy from \~10% to 5%. While this may seem like a minor improvement, freeing up CPU to be used by other critical services such as rbuilder can make the difference between winning and losing a MEV-Boost auction. ## Next Steps At the moment we’re pretty satisfied with the performance of FlowProxy, but there is still room for improvement. In particular, we haven’t yet worked on the communication between a FlowProxy instance and its local builder, which still uses JSON-RPC over HTTP, although via localhost. Assuming these two services keep running on the same machine, we could implement a shared memory based transport. While this would be clearly a benefit over the status quo, we’re coming closer to a point of diminishing returns compared to other improvements. As such, next steps could move away from FlowProxy and concentrate over a different part of the stack of BuilderNet and its architecture. # FlowProxy: Approaching Optimality import { Accordion, Accordions } from 'fumadocs-ui/components/accordion'; import { GlobalMap } from '@/components/diagrams/map'; FlowProxy V2 ## Background In [**Introducing FlowProxy**](https://collective.flashbots.net/t/introducing-flowproxy/5341), [Chainbound](https://chainbound.io/) collaborated with Flashbots to deprecate its [original orderflow proxy](https://github.com/flashbots/buildernet-orderflow-proxy) implementation in Go in favor of a new one built in Rust, created with the goal of reducing end-to-end latency, improving efficiency, and increasing observability in [BuilderNet](https://buildernet.org/)'s networking layer. This first collaboration aimed at bringing it to production-ready quality and ready for deployment, already observing good improvements in networking and processing latency. This second collaboration expanded on the *Next Steps* section outlined in the previous report, with success. It includes a quantitative analysis of order losses and latencies across BuilderNet, and extensive work to improve the transport layer to reduce both CPU and memory usage. We encountered a couple of non-obvious learnings that we outline below, and we hope this will be useful to the community. ## Network Analysis FlowProxy features a Clickhouse integration which makes possible to record *bundle receipts.* A bundle receipt consists of a short summary that includes the timestamp of when the bundle was sent and received by instances, along with its raw size in bytes (useful for tracking size → latency correlation). We used this data to better understand FlowProxy’s network performance in its entirety, and to discover what improvements to focus on. ### Bundle Loss We started with analyzing bundle loss between instances. The table below reports the percentage of bundle loss on individual links, during a small time range of congestion: | src | dst | lost | total | loss\_rate\_pct | | ---------------------- | ---------------------- | ---- | ------ | --------------- | | beaver\_eastus\_07 | nethermind\_we\_08 | 7857 | 35848 | 21.92% | | beaver\_eastus\_07 | flashbots\_we\_09 | 1969 | 96597 | 2.04% | | beaver\_eastus\_07 | beaver\_we\_08 | 1354 | 94000 | 1.44% | | beaver\_eastus\_07 | flashbots\_eastus\_10 | 2 | 140801 | 0.00% | | beaver\_eastus\_07 | nethermind\_eastus\_07 | 1 | 141534 | 0.00% | | nethermind\_eastus\_07 | flashbots\_eastus\_10 | 10 | 35315 | 0.03% | | nethermind\_eastus\_07 | beaver\_eastus\_07 | 10 | 35377 | 0.03% | | nethermind\_eastus\_07 | flashbots\_we\_09 | 6 | 34560 | 0.02% | | nethermind\_eastus\_07 | beaver\_we\_08 | 6 | 35216 | 0.02% | | nethermind\_eastus\_07 | nethermind\_we\_08 | 4 | 34490 | 0.01% | | flashbots\_eastus\_10 | beaver\_we\_08 | 4 | 34877 | 0.01% | | flashbots\_eastus\_10 | flashbots\_we\_09 | 4 | 34266 | 0.01% | | flashbots\_eastus\_10 | nethermind\_we\_08 | 3 | 34156 | 0.01% | | flashbots\_eastus\_10 | beaver\_eastus\_07 | 1 | 35054 | 0.00% | | src | dst | lost | total | loss rate | | ---------------------- | ---------------------- | ---- | ------ | --------- | | beaver\_eastus\_07 | nethermind\_we\_08 | 7857 | 35848 | 21.92% | | beaver\_eastus\_07 | flashbots\_we\_09 | 1969 | 96597 | 2.04% | | beaver\_eastus\_07 | beaver\_we\_08 | 1354 | 94000 | 1.44% | | nethermind\_eastus\_07 | flashbots\_mkosi\_1 | 58 | 31205 | 0.19% | | beaver\_eastus\_07 | flashbots\_mkosi\_1 | 13 | 125199 | 0.01% | | flashbots\_eastus\_10 | flashbots\_mkosi\_1 | 11 | 31415 | 0.04% | | nethermind\_eastus\_07 | flashbots\_eastus\_10 | 10 | 35315 | 0.03% | | nethermind\_eastus\_07 | beaver\_eastus\_07 | 10 | 35377 | 0.03% | | nethermind\_eastus\_07 | flashbots\_we\_09 | 6 | 34560 | 0.02% | | nethermind\_eastus\_07 | beaver\_we\_08 | 6 | 35216 | 0.02% | | flashbots\_eastus\_10 | beaver\_we\_08 | 4 | 34877 | 0.01% | | nethermind\_eastus\_07 | nethermind\_we\_08 | 4 | 34490 | 0.01% | | flashbots\_eastus\_10 | flashbots\_we\_09 | 4 | 34266 | 0.01% | | nethermind\_we\_08 | flashbots\_eastus\_10 | 3 | 113560 | 0.00% | | nethermind\_we\_08 | beaver\_eastus\_07 | 3 | 116296 | 0.00% | | nethermind\_we\_08 | nethermind\_eastus\_07 | 3 | 113862 | 0.00% | | flashbots\_eastus\_10 | nethermind\_we\_08 | 3 | 34156 | 0.01% | | beaver\_eastus\_07 | flashbots\_eastus\_10 | 2 | 140801 | 0.00% | | flashbots\_mkosi\_1 | beaver\_we\_08 | 1 | 95552 | 0.00% | | flashbots\_mkosi\_1 | nethermind\_eastus\_07 | 1 | 95172 | 0.00% | | nethermind\_we\_08 | beaver\_we\_08 | 1 | 104890 | 0.00% | | flashbots\_mkosi\_1 | beaver\_eastus\_07 | 1 | 96537 | 0.00% | | flashbots\_mkosi\_1 | flashbots\_we\_09 | 1 | 95015 | 0.00% | | flashbots\_eastus\_10 | beaver\_eastus\_07 | 1 | 35054 | 0.00% | | beaver\_eastus\_07 | nethermind\_eastus\_07 | 1 | 141534 | 0.00% | | flashbots\_mkosi\_1 | flashbots\_eastus\_10 | 1 | 93635 | 0.00% | | nethermind\_we\_08 | flashbots\_we\_09 | 1 | 103316 | 0.00% | Or visualized: Europe (northern route) { from: 'beaver_eastus', to: 'nethermind_we', metrics: { bundleLoss: 21.92 }, waypoints: [ [-42, 47], [-18, 49], ], }, { from: 'beaver_eastus', to: 'flashbots_we', metrics: { bundleLoss: 2.04 }, waypoints: [ [-40, 48], [-15, 50], ], }, { from: 'beaver_eastus', to: 'beaver_we', metrics: { bundleLoss: 1.44 }, waypoints: [ [-42, 44], [-18, 46], ], }, // US East local { from: 'beaver_eastus', to: 'flashbots_eastus', metrics: { bundleLoss: 0.0 }, }, { from: 'beaver_eastus', to: 'nethermind_eastus', metrics: { bundleLoss: 0.0 }, }, { from: 'nethermind_eastus', to: 'flashbots_eastus', metrics: { bundleLoss: 0.03 }, }, { from: 'nethermind_eastus', to: 'beaver_eastus', metrics: { bundleLoss: 0.03 }, }, // Transatlantic: Nethermind EUS -> Europe (middle routes) { from: 'nethermind_eastus', to: 'flashbots_we', metrics: { bundleLoss: 0.02 }, waypoints: [ [-38, 46], [-12, 48], ], }, { from: 'nethermind_eastus', to: 'beaver_we', metrics: { bundleLoss: 0.02 }, waypoints: [ [-35, 43], [-10, 45], ], }, { from: 'nethermind_eastus', to: 'nethermind_we', metrics: { bundleLoss: 0.01 }, waypoints: [ [-32, 50], [-8, 52], ], }, // Transatlantic: Flashbots EUS -> Europe (southern routes) { from: 'flashbots_eastus', to: 'beaver_we', metrics: { bundleLoss: 0.01 }, waypoints: [ [-30, 41], [-5, 44], ], }, { from: 'flashbots_eastus', to: 'flashbots_we', metrics: { bundleLoss: 0.01 }, waypoints: [ [-28, 44], [-3, 47], ], }, { from: 'flashbots_eastus', to: 'nethermind_we', metrics: { bundleLoss: 0.01 }, waypoints: [ [-25, 47], [0, 50], ], }, { from: 'flashbots_eastus', to: 'beaver_eastus', metrics: { bundleLoss: 0.0 }, }, // European interconnections { from: 'beaver_we', to: 'nethermind_we', metrics: { bundleLoss: 0.01 } }, { from: 'beaver_we', to: 'flashbots_we', metrics: { bundleLoss: 0.01 } }, { from: 'nethermind_we', to: 'flashbots_we', metrics: { bundleLoss: 0.01 }, }, ]} metricKey="bundleLoss" metricRange={[0, 25]} height={350} center={[-35, 45]} scale={350} showLegend={true} /> This map represents a simplified view of the BuilderNet topology, it's not geographically accurate. We can see in particular that inter-continental links are mostly affected by bundle loss, especially the instance `beaver_eastus_07` which receives the most flow in that region. The main reason bundles are marked as “lost” is when they time out, or when buffers overflow and backpressure is applied. This confirms what we [explored](https://collective.flashbots.net/t/introducing-flowproxy/5341#p-10727-http-connection-pools-11) in the last post: that HTTP/1.1 and its incapacity to multiplex requests makes it highly inadequate to deal with BuilderNet’s workload. ### Latency From the table below we can see latency between individual BuilderNet links during high congestion. | src | dst | p50\_ms | p99\_ms | corr\_payload\_size | observations | | ---------------------- | ---------------------- | ------- | -------- | ------------------- | ------------ | | beaver\_eastus\_07 | nethermind\_we\_08 | 39.619 | 1947.045 | -0.06 | 27991 | | beaver\_eastus\_07 | flashbots\_we\_09 | 38.022 | 1789.051 | -0.06 | 94628 | | beaver\_eastus\_07 | beaver\_we\_08 | 39.440 | 1521.599 | -0.04 | 92646 | | beaver\_eastus\_07 | nethermind\_eastus\_07 | 62.639 | 292.451 | -0.02 | 141533 | | nethermind\_eastus\_07 | beaver\_we\_08 | 33.381 | 194.660 | 0.59 | 35210 | | nethermind\_eastus\_07 | nethermind\_we\_08 | 31.541 | 193.107 | 0.58 | 34486 | | nethermind\_eastus\_07 | flashbots\_we\_09 | 30.992 | 192.134 | 0.60 | 34554 | | nethermind\_eastus\_07 | flashbots\_eastus\_10 | -0.662 | 58.648 | 0.06 | 35305 | | nethermind\_eastus\_07 | beaver\_eastus\_07 | -4.514 | 49.629 | 0.02 | 35367 | | flashbots\_eastus\_10 | beaver\_we\_08 | 40.197 | 201.535 | 0.60 | 34873 | | flashbots\_eastus\_10 | nethermind\_we\_08 | 38.467 | 200.267 | 0.59 | 34153 | | flashbots\_eastus\_10 | flashbots\_we\_09 | 37.945 | 199.814 | 0.61 | 34262 | | flashbots\_eastus\_10 | beaver\_eastus\_07 | 2.506 | 56.186 | 0.02 | 35053 | | flashbots\_eastus\_10 | nethermind\_eastus\_07 | -7.746 | 26.184 | 0.04 | 35048 | | src | dst | p50\_ms | p90\_ms | p99\_ms | p999\_ms | corr\_payload\_size | observations | | ---------------------- | ---------------------- | ------- | -------- | -------- | -------- | ------------------- | ------------ | | beaver\_eastus\_07 | nethermind\_we\_08 | 39.619 | 1668.253 | 1947.045 | 2005.370 | -0.06 | 27991 | | beaver\_eastus\_07 | flashbots\_we\_09 | 38.022 | 1414.608 | 1789.051 | 1978.707 | -0.06 | 94628 | | beaver\_eastus\_07 | beaver\_we\_08 | 39.440 | 852.713 | 1521.599 | 1970.474 | -0.04 | 92646 | | beaver\_eastus\_07 | flashbots\_mkosi\_1 | 39.591 | 419.213 | 1099.179 | 1221.009 | -0.03 | 125186 | | nethermind\_we\_08 | flashbots\_eastus\_10 | 44.564 | 126.509 | 294.049 | 502.033 | -0.21 | 113557 | | beaver\_eastus\_07 | nethermind\_eastus\_07 | 62.639 | 61.099 | 292.451 | 476.941 | -0.02 | 141533 | | flashbots\_mkosi\_1 | flashbots\_eastus\_10 | 43.261 | 84.517 | 281.969 | 560.069 | 0.16 | 93634 | | flashbots\_mkosi\_1 | beaver\_eastus\_07 | 44.489 | 66.412 | 257.931 | 519.216 | 0.24 | 96536 | | nethermind\_we\_08 | beaver\_eastus\_07 | 46.272 | 97.637 | 227.358 | 323.382 | 0.32 | 116293 | | nethermind\_we\_08 | nethermind\_eastus\_07 | 51.281 | 101.732 | 224.596 | 324.134 | 0.32 | 113859 | | nethermind\_we\_08 | beaver\_we\_08 | 25.860 | 5.459 | 216.067 | 324.788 | -0.02 | 104889 | | flashbots\_eastus\_10 | flashbots\_mkosi\_1 | 40.740 | 45.731 | 202.758 | 286.997 | 0.59 | 31404 | | flashbots\_mkosi\_1 | nethermind\_eastus\_07 | 49.122 | 61.092 | 202.012 | 273.676 | 0.37 | 95171 | | flashbots\_eastus\_10 | beaver\_we\_08 | 40.197 | 46.283 | 201.535 | 284.387 | 0.60 | 34873 | | flashbots\_eastus\_10 | nethermind\_we\_08 | 38.467 | 46.728 | 200.267 | 283.487 | 0.59 | 34153 | | flashbots\_eastus\_10 | flashbots\_we\_09 | 37.945 | 44.632 | 199.814 | 281.712 | 0.61 | 34262 | | nethermind\_eastus\_07 | flashbots\_mkosi\_1 | 33.733 | 37.802 | 195.461 | 204.237 | 0.59 | 31147 | | nethermind\_eastus\_07 | beaver\_we\_08 | 33.381 | 38.238 | 194.660 | 201.383 | 0.59 | 35210 | | nethermind\_eastus\_07 | nethermind\_we\_08 | 31.541 | 39.041 | 193.107 | 208.030 | 0.58 | 34486 | | nethermind\_eastus\_07 | flashbots\_we\_09 | 30.992 | 36.886 | 192.134 | 203.846 | 0.60 | 34554 | | flashbots\_we\_09 | flashbots\_eastus\_10 | 44.702 | 52.008 | 151.492 | 352.513 | 0.45 | 23666 | | nethermind\_we\_08 | flashbots\_mkosi\_1 | 3.024 | 5.885 | 149.235 | 237.358 | -0.02 | 128776 | | flashbots\_we\_09 | nethermind\_eastus\_07 | 51.533 | 53.694 | 135.308 | 214.133 | 0.65 | 23682 | | flashbots\_we\_09 | beaver\_eastus\_07 | 46.286 | 51.180 | 131.223 | 213.616 | 0.58 | 23676 | | beaver\_we\_08 | flashbots\_eastus\_10 | 42.202 | 45.092 | 124.000 | 320.903 | 0.46 | 21596 | | beaver\_we\_08 | beaver\_eastus\_07 | 43.786 | 46.276 | 110.435 | 222.164 | 0.54 | 21630 | | nethermind\_we\_08 | flashbots\_we\_09 | 0.300 | 4.165 | 88.781 | 154.436 | -0.01 | 105315 | | flashbots\_mkosi\_1 | nethermind\_we\_08 | -1.529 | 1.878 | 84.565 | 172.061 | -0.02 | 109297 | | beaver\_we\_08 | nethermind\_eastus\_07 | 49.078 | 50.298 | 62.551 | 215.596 | 0.82 | 21608 | | nethermind\_eastus\_07 | flashbots\_eastus\_10 | -0.662 | -2.668 | 58.648 | 220.645 | 0.06 | 35305 | | flashbots\_mkosi\_1 | beaver\_we\_08 | -0.075 | 2.209 | 57.603 | 115.534 | -0.02 | 95551 | | flashbots\_eastus\_10 | beaver\_eastus\_07 | 2.506 | 5.522 | 56.186 | 188.124 | 0.02 | 35053 | | flashbots\_mkosi\_1 | flashbots\_we\_09 | 2.188 | 9.008 | 52.778 | 91.397 | -0.00 | 95014 | | nethermind\_eastus\_07 | beaver\_eastus\_07 | -4.514 | -1.465 | 49.629 | 194.338 | 0.02 | 35367 | | flashbots\_eastus\_10 | nethermind\_eastus\_07 | -7.746 | -9.508 | 26.184 | 101.051 | 0.04 | 35048 | | flashbots\_we\_09 | nethermind\_we\_08 | -1.307 | 3.241 | 17.573 | 83.503 | 0.02 | 23585 | | beaver\_we\_08 | nethermind\_we\_08 | 1.076 | 3.533 | 14.321 | 98.753 | 0.03 | 21147 | | flashbots\_we\_09 | flashbots\_mkosi\_1 | 3.017 | 4.058 | 11.831 | 43.950 | 0.06 | 23651 | | beaver\_we\_08 | flashbots\_mkosi\_1 | 3.506 | 4.566 | 9.304 | 22.996 | 0.10 | 23289 | | beaver\_we\_08 | flashbots\_we\_09 | 1.137 | 1.804 | 8.416 | 25.661 | 0.10 | 20771 | | beaver\_we\_08 | flashbots\_we\_09 | -1.624 | -0.159 | 8.029 | 67.550 | 0.05 | 20979 | The negative p50 latencies here are due to [clock drift](https://en.wikipedia.org/wiki/Clock_drift). Or, with P99 latencies visualized: Europe (high latency routes) { from: 'beaver_eastus', to: 'nethermind_we', metrics: { latency: 1947.05 }, waypoints: [ [-42, 47], [-18, 49], ], }, { from: 'beaver_eastus', to: 'flashbots_we', metrics: { latency: 1789.05 }, waypoints: [ [-40, 48], [-15, 50], ], }, { from: 'beaver_eastus', to: 'beaver_we', metrics: { latency: 1521.6 }, waypoints: [ [-42, 44], [-18, 46], ], }, // US East local { from: 'beaver_eastus', to: 'nethermind_eastus', metrics: { latency: 292.45 }, }, { from: 'nethermind_eastus', to: 'flashbots_eastus', metrics: { latency: 58.65 }, }, { from: 'nethermind_eastus', to: 'beaver_eastus', metrics: { latency: 49.63 }, }, { from: 'flashbots_eastus', to: 'beaver_eastus', metrics: { latency: 56.19 }, }, { from: 'flashbots_eastus', to: 'nethermind_eastus', metrics: { latency: 26.18 }, }, // Transatlantic: Nethermind EUS -> Europe { from: 'nethermind_eastus', to: 'beaver_we', metrics: { latency: 194.66 }, waypoints: [ [-35, 43], [-10, 45], ], }, { from: 'nethermind_eastus', to: 'nethermind_we', metrics: { latency: 193.11 }, waypoints: [ [-32, 50], [-8, 52], ], }, { from: 'nethermind_eastus', to: 'flashbots_we', metrics: { latency: 192.13 }, waypoints: [ [-38, 46], [-12, 48], ], }, // Transatlantic: Flashbots EUS -> Europe { from: 'flashbots_eastus', to: 'beaver_we', metrics: { latency: 201.54 }, waypoints: [ [-30, 41], [-5, 44], ], }, { from: 'flashbots_eastus', to: 'nethermind_we', metrics: { latency: 200.27 }, waypoints: [ [-25, 47], [0, 50], ], }, { from: 'flashbots_eastus', to: 'flashbots_we', metrics: { latency: 199.81 }, waypoints: [ [-28, 44], [-3, 47], ], }, ]} metricKey="latency" metricRange={[0, 2000]} height={350} center={[-35, 45]} scale={350} showLegend={true} /> As with the previous table, we can see the highest latency between the most active inter-region links, peaking at nearly 2s in p99. Another interesting result is the correlation with payload size (`corr_payload_size`): in normal working conditions (low p99s), it is more pronounced. This could mean a couple of things that will be useful to know later: * We’re not able to send large payloads in a single transmission (related to [BDP](https://en.wikipedia.org/wiki/Bandwidth-delay_product), which we’ll talk about below). * Preparing the order for transmission, or processing the order on the receiver side, takes a noticeably longer time the bigger the message. Some correlation is expected here, but it should be minimal. The main processing steps in the hot path here are JSON encoding / decoding, and signing / signature verification. ## Improvements ### Thread modelling FlowProxy runs with the [Tokio](https://docs.rs/tokio/latest/tokio/) asynchronous runtime. The initial implementation of the proxy indiscriminately used Tokio tasks for all different kind of workloads, including CPU intensive operations like signature recovery, signing and decoding transactions. This approach is not ideal because the runtime and its tasks are fundamentally optimized for non-blocking, I/O-bound work, and using it for other [blocking or CPU-bound work](https://docs.rs/tokio/latest/tokio/index.html#cpu-bound-tasks-and-blocking-code) *will* increase tail latencies. Tokio schedules many lightweight tasks onto a small number of OS threads. If a task performs CPU-heavy or blocking work, it can monopolise a worker thread, preventing other tasks from making progress. We suspected this could partly be causing some of the high tail latencies we were seeing. The Tokio authors recommend using [`tokio::task::spawn_blocking`](https://docs.rs/tokio/latest/tokio/task/fn.spawn_blocking.html), which we tried initially. This will spawn (or reuse) a thread managed by the runtime that is purely used for CPU-bound and blocking operations. However, this resulted in many more threads being spawned than we knew was necessary, and also had more overhead than expected. We didn’t dive into this too much, but intuitively it seemed like the blocking thread scheduler was not reusing threads effectively ([Github issue](https://github.com/BuilderNet/FlowProxy/pull/141)). To mitigate this, we introduced a configurable pool of specialised [rayon](https://docs.rs/rayon/latest/rayon/) threads for compute-heavy operations, and tweaked the number of Tokio worker threads to match production environment requirements. This setup allows also to have a healthy environment where we can control how much resources a certain service is using, since the same machine would also run other processes like the block builder. **Side Note** While experimenting with the parameters, we observed how moving *some* specific compute-heavy operations resulted in a worse order processing latency. An interesting learning was that on a busy machine with many threads, sending tasks off to a different thread than the currently executing one *can* increase latency significantly. The cost of scheduling a new thread (context switching, waiting) should be taken into account, and is very context dependent! ### HTTP/2 HTTP/2 was designed to address various performance limitations of HTTP/1.1 while keeping the same semantics. Among various improvements, the most impactful for FlowProxy is **multiplexing**: with HTTP/1.1 only one request/response can be in flight per TCP connection (called *head-of-line blocking),* while HTTP/2 allows multiplexing multiple requests and responses over a single TCP connection, using *streams*. Streams are logical, bidirectional channels within one connection. They’re managed by *windows*: credit-based flow control mechanisms to limit how much data can be sent on a single stream, to ensure it doesn’t starve the connection. This multiplexing allowed us to greatly reduce the number of open connections, and improve connection reuse, which we already hinted was a source of message loss. Upgrading to HTTP/2 was the first improvement we rolled out, because of its complete backwards compatibility: communication between and towards instances running on a previous version of FlowProxy would simply fallback to HTTP/1.1. Below, you can see how the number of failures (read: lost messages) have been essentially reduced to 0 after its deployment. HTTP failures after deployment of HTTP/2 HTTP failures after deployment of HTTP/2 While request failures dropped, latency didn’t significantly improve. In particular, we’ve observed some improvement over small requests (with body size less than 32KiB) over inter-regional links, as we can see below. However, for same-region requests and bigger messages the situation remained identical or slightly worsened. RPC call duration latency (p99) before and after the deployment of HTTP/2. RPC call duration latency (p99) before and after the deployment of HTTP/2. This was a very different result compared to our staging environment, consisting of four nodes distributed between East US and West Europe. We think the main culprit is an overall different topology and network load compared to the production environment, which would be hard to completely emulate. After this result, we started looking into tuning configurations. FlowProxy instances operate with a reverse [HAProxy](https://www.haproxy.org/) that sits before the user and system endpoint, with the latter used for internal orderflow sharing. The proxy exposes some [HTTP/2 tuning configurations](https://docs.haproxy.org/3.2/configuration.html#tune.h2.be.initial-window-size:~:text=%2D%20tune.h2,copy%2Dfwd%2Dsend) that could help further reducing latency and spikiness. The dimensions in which we could operate were: * The number of maximum open streams; * The size of the window buffers; * Creating dedicated clients for small and big requests. While tuning those resulted in marginal improvements, we were still working on high-level abstractions, without much control over the metal. Moreover, HTTP/2 windows play a similar role to TCP congestion control / window scaling, resulting in some overhead and confusion about how the two interoperate. Because of this, we decided to pause HTTP/2 tuning efforts, and focus on a full migration to raw TCP (with TLS) with the [msg-rs](https://github.com/chainbound/msg-rs) messaging library. **Bundle loss after HTTP/2** After the deployment of this improvement, we analyzed bundle loss once again (read a full analysis [**here**](https://www.notion.so/PUBLIC-FlowProxy-Bundle-Receipts-Analysis-v2-2a45abfafc1980a29bb9fda91b3dd16d?pvs=21)). The table below contains a day worth of data, that includes both periods of low activity and high activity. We can see that bundle loss has essentially disappeared. Sample for 2025-11-14: | src | dst | lost | total | | ---------------------- | ---------------------- | ---- | -------- | | nethermind\_eastus\_07 | beaver\_we\_08 | 28 | 8894080 | | beaver\_eastus\_07 | flashbots\_eastus\_10 | 8 | 3001624 | | beaver\_eastus\_07 | flashbots\_we\_09 | 8 | 11168780 | | beaver\_eastus\_07 | nethermind\_eastus\_07 | 8 | 30111251 | | beaver\_eastus\_07 | beaver\_we\_08 | 8 | 10864297 | | beaver\_eastus\_07 | nethermind\_we\_08 | 8 | 10069603 | | nethermind\_eastus\_07 | flashbots\_we\_09 | 10 | 9028245 | | nethermind\_eastus\_07 | nethermind\_we\_08 | 8 | 9052880 | | nethermind\_eastus\_07 | beaver\_eastus\_07 | 6 | 10309013 | | nethermind\_eastus\_07 | flashbots\_eastus\_10 | 5 | 10302474 | | flashbots\_eastus\_10 | beaver\_we\_08 | 14 | 9024652 | | flashbots\_eastus\_10 | nethermind\_eastus\_07 | 6 | 10443797 | | flashbots\_eastus\_10 | beaver\_eastus\_07 | 6 | 10458639 | | flashbots\_eastus\_10 | nethermind\_we\_08 | 4 | 9087691 | | flashbots\_eastus\_10 | flashbots\_we\_09 | 2 | 9115212 | | src | dst | count | total | loss pctg | | --------------------------------------------- | --------------------------------------------- | ----- | -------- | --------- | | flashbots\_test\_1 | beaver\_eastus\_07 | 47 | 5297796 | 0% | | buildernet\_nethermind\_azure\_eastus\_07 | buildernet\_beaver\_azure\_westeurope\_08 | 28 | 8894080 | 0% | | buildernet\_beaver\_azure\_westeurope\_08 | buildernet\_beaver\_azure\_eastus\_07 | 24 | 5565633 | 0% | | buildernet\_flashbots\_azure\_eastus\_10 | buildernet\_flashbots\_mkosi\_test\_1 | 18 | 9283988 | 0% | | buildernet\_beaver\_azure\_westeurope\_08 | buildernet\_nethermind\_azure\_eastus\_07 | 16 | 5547538 | 0% | | buildernet\_beaver\_azure\_eastus\_07 | buildernet\_flashbots\_mkosi\_test\_1 | 16 | 10189003 | 0% | | buildernet\_flashbots\_azure\_eastus\_10 | buildernet\_beaver\_azure\_westeurope\_08 | 14 | 9024652 | 0% | | buildernet\_beaver\_azure\_westeurope\_08 | buildernet\_flashbots\_azure\_eastus\_10 | 10 | 5578403 | 0% | | buildernet\_nethermind\_azure\_eastus\_07 | buildernet\_flashbots\_azure\_westeurope\_09 | 10 | 9028245 | 0% | | buildernet\_flashbots\_mkosi\_test\_1 | buildernet\_flashbots\_azure\_eastus\_10 | 9 | 6146894 | 0% | | buildernet\_flashbots\_mkosi\_test\_1 | buildernet\_nethermind\_azure\_eastus\_07 | 9 | 6222701 | 0% | | buildernet\_nethermind\_azure\_eastus\_07 | buildernet\_nethermind\_azure\_westeurope\_08 | 8 | 9052880 | 0% | | buildernet\_beaver\_azure\_eastus\_07 | buildernet\_flashbots\_azure\_eastus\_10 | 8 | 3001624 | 0% | | buildernet\_beaver\_azure\_eastus\_07 | buildernet\_flashbots\_azure\_westeurope\_09 | 8 | 11168780 | 0% | | buildernet\_beaver\_azure\_eastus\_07 | buildernet\_nethermind\_azure\_eastus\_07 | 8 | 30111251 | 0% | | buildernet\_beaver\_azure\_eastus\_07 | buildernet\_beaver\_azure\_westeurope\_08 | 8 | 10864297 | 0% | | buildernet\_beaver\_azure\_eastus\_07 | buildernet\_nethermind\_azure\_westeurope\_08 | 8 | 10069603 | 0% | | buildernet\_nethermind\_azure\_westeurope\_08 | buildernet\_flashbots\_azure\_eastus\_10 | 7 | 6573168 | 0% | | buildernet\_flashbots\_azure\_westeurope\_09 | buildernet\_beaver\_azure\_eastus\_07 | 7 | 6078754 | 0% | | buildernet\_nethermind\_azure\_eastus\_07 | buildernet\_beaver\_azure\_eastus\_07 | 6 | 10309013 | 0% | | buildernet\_flashbots\_azure\_eastus\_10 | buildernet\_nethermind\_azure\_eastus\_07 | 6 | 10443797 | 0% | | buildernet\_nethermind\_azure\_eastus\_07 | buildernet\_flashbots\_mkosi\_test\_1 | 6 | 8967508 | 0% | | buildernet\_flashbots\_azure\_eastus\_10 | buildernet\_beaver\_azure\_eastus\_07 | 6 | 10458639 | 0% | | buildernet\_nethermind\_azure\_westeurope\_08 | buildernet\_nethermind\_azure\_eastus\_07 | 5 | 6513729 | 0% | | buildernet\_nethermind\_azure\_westeurope\_08 | buildernet\_beaver\_azure\_eastus\_07 | 5 | 6194898 | 0% | | buildernet\_nethermind\_azure\_eastus\_07 | buildernet\_flashbots\_azure\_eastus\_10 | 5 | 10302474 | 0% | | buildernet\_flashbots\_azure\_eastus\_10 | buildernet\_nethermind\_azure\_westeurope\_08 | 4 | 9087691 | 0% | | buildernet\_flashbots\_azure\_westeurope\_09 | buildernet\_flashbots\_azure\_eastus\_10 | 3 | 6117425 | 0% | | buildernet\_flashbots\_azure\_eastus\_10 | buildernet\_flashbots\_azure\_westeurope\_09 | 2 | 9115212 | 0% | | buildernet\_flashbots\_azure\_westeurope\_09 | buildernet\_flashbots\_mkosi\_test\_1 | 1 | 7617802 | 0% | | buildernet\_flashbots\_mkosi\_test\_1 | buildernet\_beaver\_azure\_westeurope\_08 | 1 | 18836193 | 0% | | buildernet\_flashbots\_mkosi\_test\_1 | buildernet\_flashbots\_azure\_westeurope\_09 | 1 | 18043957 | 0% | | buildernet\_nethermind\_azure\_westeurope\_08 | buildernet\_flashbots\_mkosi\_test\_1 | 1 | 9123905 | 0% | | buildernet\_nethermind\_azure\_westeurope\_08 | buildernet\_beaver\_azure\_westeurope\_08 | 1 | 13247037 | 0% | | buildernet\_flashbots\_azure\_westeurope\_09 | buildernet\_nethermind\_azure\_eastus\_07 | 1 | 6076706 | 0% | | buildernet\_flashbots\_mkosi\_test\_1 | buildernet\_nethermind\_azure\_westeurope\_08 | 1 | 7608331 | 0% | **HTTP/2 latency** Below we can see a more complete before-after of latency between links. We can see that after HTTP/2 we see more bounded p99 and p999, while p50 and p90 stayed almost the same. While picking a single day from both deployments may not be completely indicative, the behaviour remained quite consistent during the next days. Latency comparison of P99 latencies befor, each with 24 hours worth of datae and after HTTP/2 for a day's worth of data (all numbers in milliseconds): | src | dst | p50\_a | p50\_b | Δp50 | p99\_a | p99\_b | Δp99 | | ---------------------- | --------------------- | ------ | ------ | ---- | ------ | ------ | ---- | | nethermind\_we\_08 | beaver\_eastus\_07 | 42 | 42 | \~0 | 458 | 155 | -303 | | nethermind\_we\_08 | flashbots\_eastus\_10 | 42 | 41 | -1 | 455 | 142 | -313 | | beaver\_eastus\_07 | nethermind\_we\_08 | 44 | 41 | -3 | 357 | 125 | -232 | | beaver\_eastus\_07 | flashbots\_we\_09 | 45 | 46 | +1 | 297 | 122 | -175 | | nethermind\_eastus\_07 | flashbots\_we\_09 | 48 | 45 | -3 | 299 | 120 | -179 | | flashbots\_eastus\_10 | nethermind\_we\_08 | 42 | 43 | +1 | 206 | 119 | -87 | | flashbots\_eastus\_10 | flashbots\_we\_09 | 43 | 44 | +1 | 206 | 106 | -100 | | flashbots\_eastus\_10 | beaver\_eastus\_07 | 43 | 44 | +1 | 284 | 131 | -153 | | nethermind\_eastus\_07 | beaver\_eastus\_07 | 39 | 41 | +2 | 200 | 96 | -104 | | beaver\_eastus\_07 | flashbots\_eastus\_10 | 40 | 41 | +1 | 197 | 103 | -94 | ### TCP + `bitcode` To preserve backwards compatibility, we offered a new TCP-only version of system API along with the existing HTTP/2 one. Using a new temporary endpoint allowed us to experiment with binary encoding format, so we could finally remove JSON which other than offering high decoding latency, resulted in \~50% bigger payloads than necessary due to encoding hex data as strings. First, we tried to look into `serde` compatible binary encoding formats, like `bincode` and MessagePack, but both of them do not have full feature compatibility with `serde_derive`. Because of that, we opted out from `serde` compatibility and went with [`bitcode`](https://github.com/SoftbearStudios/bitcode) instead, which currently sits at the top of the [Rust Serialization Benchmark](https://david.kolo.ski/rust_serialization_benchmark/) leaderboard. This was the result of both a switch to TCP (without any additional tuning yet), and migration from JSON to bitcode: image.png However, there’s a big caveat here: we only saw these levels of improvements on very specific links. The plot above depicts latency from a specifically broadcast-heavy instance: one that ingests and forwards a lot of orders. We barely saw any improvements on other instances, which led us down the rabbit hole of TCP kernel parameters. ### Kernel Parameter Tuning In order to get the most of raw TCP sockets, it has been fundamental to tune kernel parameters to achieve optimised connections. To understand better the changes we’ve done, let’s brush up on some preliminaries. The *bandwidth-delay product*, or BDP, is a property of a network path, and is computed as the product of bandwidth, expressed in bytes per second, and round-trip time (delay), expressed in seconds. In the context of TCP, it represents how much in-flight data the connection can hold before the sender must wait for acknowledgments from the receiver. Let’s make a concrete example: a node in FlowProxy has a upload bandwidth of 1 Gbps, and an Azure link between East US and West Europe is around 85ms ([source](https://learn.microsoft.com/en-us/azure/networking/azure-network-latency?tabs=Americas%2CEastUS)). This results in a BDP of `1 Gbps × 85 ms = 85 Mbit = 10.625 MB`, meaning that is the theoretical maximum of data we can have unacknowledged in TCP, assuming ideal network conditions. Related to BDP is the *congestion window* (referred as `cwnd` in code). It is a core TCP mechanism that controls how much data a sender is allowed to have “in flight” (sent, but not yet acknowledged) at any given time. On the receiver side, there’s `rwnd` (the receive window), which determines how much unacknowledged data can accumulate at the receiver side before packets are dropped. The minimum of `cwnd` and `rwnd` determines your maximum throughput. One direct consequence of BDP is the following: if `min(cwnd, rwnd) < size(message)`, that message will need an **additional round trip to fully transmit!** By definition, the theoretical maximum size of the congestion window for communicating over a link matches the BDP. That would mean there is no congestion at all! However, the network might not be always stable, and packet loss might happen and RTT may vary over time. As such, the congestion window dynamically adapts to the appropriate amount of unacknowledged data to not waste any resources. These mechanisms are called *TCP congestion control algorithms.* All of these parameters can be modified in the Linux kernel with `sysctl`. The following are the most important: * `net.ipv4.tcp_congestion_control`: the default (`cubic`) worked for us. * `net.ipv4.tcp_window_scaling`: ensure this is turned on. * `net.ipv4.tcp_rmem`: sets the bounds for `rwnd`. Default and max had to be significantly increased, some multiple of your expected max message size is a good guideline. Linux will take care of autoscaling this if window scaling is turned on. Other than congestion algorithms, there are other settings that may impact the congestion window. One of them is called “slow start after idle”, which `sysctl` setting is `net.ipv4.tcp_slow_start_after_idle` . A connection is considered idle if a packet hasn’t been sent for a certain amount of time, computed as a function of the RTT, but with a default minimum of 200ms that we would be used in case of a 85ms RTT. In case of idleness, the congestion window is greatly reduced, diminishing throughput and increasing latency. Given connection between FlowProxy instances are long lived, and can be bursty in period of high-traffic, this setting should be disabled. In the picture below you can see the tremendous impact of the switch on low volume instances: Call duration latency (p99) before and after the net.ipv4.tcp_slow_start_after_idle has been disabled. Call duration latency (p99) before and after the `net.ipv4.tcp_slow_start_after_idle` has been disabled. This setting was exactly why the high message volume instances displayed really good latency after the switch to TCP, and the others didn’t: their TCP connections were never idle, so the congestion windows were never reset. The general takeaway here is that Linux TCP settings are very conservative and always try to save on resources, and should almost always be changed depending on your workload. ### Latency Comparison After all these modifications, we were finally approaching optimality in terms of order propagation: variance for big and small messages, over long and short links was reduced significantly. Check out the full comparison of P99 latencies below, each with 24 hours worth of data: **Before** Europe (high latency routes) { from: 'beaver_eastus', to: 'nethermind_we', metrics: { latency: 1947.05 }, waypoints: [ [-42, 47], [-18, 49], ], }, { from: 'beaver_eastus', to: 'flashbots_we', metrics: { latency: 1789.05 }, waypoints: [ [-40, 48], [-15, 50], ], }, { from: 'beaver_eastus', to: 'beaver_we', metrics: { latency: 1521.6 }, waypoints: [ [-42, 44], [-18, 46], ], }, // US East local { from: 'beaver_eastus', to: 'nethermind_eastus', metrics: { latency: 292.45 }, }, { from: 'nethermind_eastus', to: 'flashbots_eastus', metrics: { latency: 58.65 }, }, { from: 'nethermind_eastus', to: 'beaver_eastus', metrics: { latency: 49.63 }, }, { from: 'flashbots_eastus', to: 'beaver_eastus', metrics: { latency: 56.19 }, }, { from: 'flashbots_eastus', to: 'nethermind_eastus', metrics: { latency: 26.18 }, }, // Transatlantic: Nethermind EUS -> Europe { from: 'nethermind_eastus', to: 'beaver_we', metrics: { latency: 194.66 }, waypoints: [ [-35, 43], [-10, 45], ], }, { from: 'nethermind_eastus', to: 'nethermind_we', metrics: { latency: 193.11 }, waypoints: [ [-32, 50], [-8, 52], ], }, { from: 'nethermind_eastus', to: 'flashbots_we', metrics: { latency: 192.13 }, waypoints: [ [-38, 46], [-12, 48], ], }, // Transatlantic: Flashbots EUS -> Europe { from: 'flashbots_eastus', to: 'beaver_we', metrics: { latency: 201.54 }, waypoints: [ [-30, 41], [-5, 44], ], }, { from: 'flashbots_eastus', to: 'nethermind_we', metrics: { latency: 200.27 }, waypoints: [ [-25, 47], [0, 50], ], }, { from: 'flashbots_eastus', to: 'flashbots_we', metrics: { latency: 199.81 }, waypoints: [ [-28, 44], [-3, 47], ], }, ]} metricKey="latency" metricRange={[0, 2000]} height={350} center={[-35, 45]} scale={350} showLegend={true} /> The maximum P99 here is ~1950ms. **After** Europe { from: 'beaver_eastus', to: 'nethermind_we', metrics: { latency: 50.06 }, waypoints: [ [-42, 47], [-18, 49], ], }, { from: 'beaver_eastus', to: 'flashbots_we', metrics: { latency: 43.51 }, waypoints: [ [-40, 48], [-15, 50], ], }, { from: 'beaver_eastus', to: 'beaver_we', metrics: { latency: 43.82 }, waypoints: [ [-42, 44], [-18, 46], ], }, // US East local { from: 'beaver_eastus', to: 'nethermind_eastus', metrics: { latency: 7.55 }, }, { from: 'nethermind_eastus', to: 'flashbots_eastus', metrics: { latency: 9.0 }, }, { from: 'nethermind_eastus', to: 'beaver_eastus', metrics: { latency: 7.55 }, }, { from: 'flashbots_eastus', to: 'beaver_eastus', metrics: { latency: 4.4 }, }, { from: 'flashbots_eastus', to: 'nethermind_eastus', metrics: { latency: 2.25 }, }, // Transatlantic: Nethermind EUS -> Europe { from: 'nethermind_eastus', to: 'beaver_we', metrics: { latency: 47.88 }, waypoints: [ [-35, 43], [-10, 45], ], }, { from: 'nethermind_eastus', to: 'nethermind_we', metrics: { latency: 46.47 }, waypoints: [ [-32, 50], [-8, 52], ], }, { from: 'nethermind_eastus', to: 'flashbots_we', metrics: { latency: 48.87 }, waypoints: [ [-38, 46], [-12, 48], ], }, // Transatlantic: Flashbots EUS -> Europe { from: 'flashbots_eastus', to: 'beaver_we', metrics: { latency: 42.32 }, waypoints: [ [-30, 41], [-5, 44], ], }, { from: 'flashbots_eastus', to: 'nethermind_we', metrics: { latency: 45.89 }, waypoints: [ [-25, 47], [0, 50], ], }, { from: 'flashbots_eastus', to: 'flashbots_we', metrics: { latency: 44.28 }, waypoints: [ [-28, 44], [-3, 47], ], }, ]} metricKey="latency" metricRange={[0, 2000]} height={350} center={[-35, 45]} scale={350} showLegend={true} /> The maximum P99 here is ~50ms. There was one more change that we knew would be impactful, not on latency but on CPU usage. ### mTLS The last improvement we’ve been working on during this collaboration has been [mutual TLS](https://www.cloudflare.com/learning/access-management/what-is-mutual-tls/) (mTLS). If required by the server, the client must present a valid X509 TLS certificate to authenticate itself. This works, because all instances know each other's TLS certificates through a central service registry. Currently, FlowProxy authenticates other instances on a message-by-message basis: every message it gets needs to be signed by a known ECDSA public key. This is secure, but wasteful: every message needs to be a) signed by the sender, and b) verified by the receiver. mTLS would change authentication on a message basis, to authentication on a connection basis. Authentication would occur during connection setup, and after a successful authentication, all messages sent on that connection are transitively authenticated too. As of December 22, 2025, this feature hasn’t been deployed yet on a production environment, but from the picture below we can already appreciate its effects on the staging environment. Global CPU usage and thread usage after deployment of mTLS on a staging environment. Global CPU usage and thread usage after deployment of mTLS on a staging environment. From this preliminary result we can see a 50% reduction of CPU and thread usage, lowering total CPU usage of FlowProxy from \~10% to 5%. While this may seem like a minor improvement, freeing up CPU to be used by other critical services such as rbuilder can make the difference between winning and losing a MEV-Boost auction. ## Next Steps At the moment we’re pretty satisfied with the performance of FlowProxy, but there is still room for improvement. In particular, we haven’t yet worked on the communication between a FlowProxy instance and its local builder, which still uses JSON-RPC over HTTP, although via localhost. Assuming these two services keep running on the same machine, we could implement a shared memory based transport. While this would be clearly a benefit over the status quo, we’re coming closer to a point of diminishing returns compared to other improvements. As such, next steps could move away from FlowProxy and concentrate over a different part of the stack of BuilderNet and its architecture. # Glamsterdam Headliners The following document contains Chainbound’s take on what the headliners should be on the upcoming Glamsterdam hard-fork of Ethereum, according to the format specified in this [ethereum-magicians post](https://ethereum-magicians.org/t/soliciting-stakeholder-feedback-on-glamsterdam-headliners/24885). ## Priorities **Question: What do you view as the top priority theme in this fork & why?** *e.g. censorship resistance, scaling the L1, improving UX, etc.* We see scaling the L1 in terms of throughput and lowering fees as the main theme for this fork. If done properly, without introducing new negative externalities and by lowering down the technical debt of the protocol, it has downstream effects in both the rollup roadmap and for improving UX for a wide variety of users. This process makes Ethereum as a global platform more attractive for everyone, fostering its growth in multiple directions. We see also censorship resistance as an equally important part of the roadmap, however we’re not completely satisfied with current proposals, as motived below. ## Headliners **Question: Which EIP(s) do you favor as a headliner for Glamsterdam?** The process is aiming for one EIP each for the consensus and execution layers. Execution Layer: EIP-7928 — Block Level Access list. This is clearly a low-hanging fruit improvement that is both future-proof (e.g. non-contentious with EVM upgrades) and with basically no tradeoffs. It benefits a wide variety of users: both users of L1 dApps because of cheaper usage of read/writes on storage, and users on L2s, as carefully explained by @donnoh. Consensus Layer: EIP-7732 — enshrined Proposer-Bulider Separation. This EIP creates a framework to better spreads tasks and resources over the slot, favoring both home-stakers and future improvements like APS and ETs. We basically agree with what’s mentioned in this [post](https://ethereum-magicians.org/t/eip-7732-the-case-for-inclusion-in-glamsterdam/24306#p-59218-detailed-justification-3). We acknowledge the following open questions of the proposal: * The existence of the “[Free Option Problem](https://collective.flashbots.net/t/the-free-option-problem-in-epbs/5115)” — Thanks to some comments (see [here](https://collective.flashbots.net/t/the-free-option-problem-in-epbs/5115/2) and [here](https://x.com/hasufl/status/1949093915950186547)), we consider it more an independent problem of the already existing PBS market structure. In our opinion, this EIP more concerned with efficient slot restructuring rather than enshrining a specific market structure. For example, by requiring a validator signature over the transaction list in the execution header, we could keep the pipelining benefits while killing the PBS market structure. * DA attestation deadline — The current proposal states that the payload and the blobs must be observed by the Payload-Timeliness-Committee at the same time. As such, moving the PTC deadline to favour execution time would result in less blob propagation time. To contrast this, we’re fully supportive of the [Dual-deadline PTC vote](https://notes.ethereum.org/@anderselowsson/Dual-deadlinePTCvote) proposal. Lastly, we mark EIP-7805 (FOCIL) as the second best candidate for the Glamsterdam CL headliner. However, we have minor questions regarding its implementation, expressed in the “concern” section. ## Concerns **Question: The leading headliners among client teams are described in a [series of blog posts listed here](https://forkcast.org/upgrade/glamsterdam#client-team-perspectives). Do you have any concerns about any particular proposal?** As mentioned above, we consider FOCIL as the 2nd best option for the CL headliner in Glamsterdam. However, we have some minor concerns about its implementation: 1. It doesn’t support blob transactions, so it means support for it must come in another hard-fork that might change how blob transactions are propagated. While some proposals do exist (for example, our own [blob notaries design](https://ethresear.ch/t/blob-notaries-a-distributed-blob-publishing-design-to-scale-da/22709)), there isn’t a concrete roadmap to make it happen as far as we know. With Ethereum doubling down in increasing its blob count, supporting them becomes more and more relevant. 2. For rollups, if censored, settling becomes much harder due to the 8KiB as max IL size. Moreover, recent updates such as [EIP-7623](https://eips.ethereum.org/EIPS/eip-7623) make using large amount of calldata more expensive than before. 3. The current proposal of FOCIL states that [the inclusion list building logic is up to the consensus clients implementation](https://eips.ethereum.org/EIPS/eip-7805#il-building). In our opinion, assessing which transactions are being censored is a very hard task, unless explicitly part of the OFAC list. This would lead to a risk of wasting a small yet extremely precious resource. Examples include: 1. Picking at random from the mempool can lead to not choosing a censored transaction for some slots in a row, degrading eventual censorship resistance UX. 2. Picking the highest paying transactions can lead to IL members picking the same small subset of mempool transactions that fit in a 8KiB IL. 3. Picking transactions that have been in the mempool for a while can incentivise non-censored users flooding with low-priority transactions (e.g. 1 wei priority fee). While we acknowledge the difficulty of this problem, we see more suited a design where the user explicitly ask for its transaction to be force-included in the next blocks in exchange for a tip. A tip is justified to avoid spam and because the service done by the protocol is converting from probabilistic inclusion to deterministic inclusion. # Introducing FlowProxy **TLDR**; [Chainbound](https://chainbound.io) collaborated with Flashbots to deprecate its [original orderflow proxy](https://github.com/flashbots/buildernet-orderflow-proxy) implementation in Go in favor of a new one built in Rust, created with the goal of reducing E2E latency and increasing observability in [BuilderNet](https://buildernet.org/). We’re calling it [FlowProxy](https://github.com/BuilderNet/FlowProxy). Over a 1-month period, we picked up the work on the new Rust implementation to get it production ready. Along the way, we carefully profiled and benchmarked any changes to ensure they met BuilderNet performance requirements. We write about this journey below. FlowProxy has been live on all BuilderNet instances since October 17th. FlowProxy ## Background The [orderflow proxy](https://github.com/flashbots/buildernet-orderflow-proxy) is a central component in BuilderNet. It’s responsible for receiving bundles and transactions from users, and ensuring those orders are shared with all other instances in BuilderNet, as well as to the local [rbuilder](https://github.com/flashbots/rbuilder). Performance is an important aspect here: the faster these proxies can share orders, the more effective the builders will be able to collaborate, and the more blocks BuilderNet as a whole will win. If a proxy goes down for even a couple of seconds, that could mean 1000s of lost orders, so reliability is crucial too. Simplified diagram of BuilderNet topology Simplified diagram of BuilderNet topology For all of these reasons, it was decided that the successor to the legacy proxy should be written in Rust. We were tasked with taking over the initial work and making it production ready. One of our goals was to ensure this new version would outperform v1, without any drastic changes yet. In this post, we’ll go over our methodology, findings and challenges. ## Methodology The first goal of this project was to create a realistic testbed for integration tests, e2e tests, compatibility tests, profiling CPU and measuring network throughput and latencies. These last 3 measurements would help us assess, quite quickly, if we’re meeting the performance requirements as we continued the development. When talking about performance in this context, we’re primarily talking about various types of latencies or delays. Examples of manifestations that are relevant here include **processing latencies**, where the CPU is busy doing some computations (in the critical path), and **network latencies** between instances determined by their physical distance and link capacities. We can define the critical path as follows: the “wire-to-wire” latency between the proxy receiving an order, and the order leaving the program to be sent to the next destination. This primarily includes a bunch of order validation routines and some cryptographic operations such as signature verification. ### Stage 1: Network simulation & profiling To replicate a realistic testbed we turned to [Shadow](https://shadow.github.io/docs/guide/), a discrete-event network simulator that directly executes your binaries as-is. It allows you to specify network topologies in [scenario files](https://github.com/flashbots/buildernet-orderflow-proxy-v2/blob/main/simulation/scenarios/network-graph-2-zones.yaml) that will result in modified traffic patterns to follow these configured bandwidths and latencies. Shadow directly intercepts syscalls coming from your binary, and in the case of intercepted network packets, will route them over its simulated, deterministic networking stack. We created a scenario that mirrored a real-world BuilderNet deployment, and then replayed live traffic captured previously at various rates. We then captured packets, logged the timestamps for order receipts at all the proxies, and profiled the application with perf and flamegraph. With some additional tooling, we could very quickly iterate on changes and see the direct impact in the form of outputs like (printout of `./sim.sh process`): ``` Processing results/bundle_receipts_2025-10-03-08-57-50_runtime-5m_scale-5.parquet, results/proxy1_eth0_2025-10-03-08-57-50_runtime-5m_scale-5_summary.csv and results/proxy2_eth0_2025-10-03-08-57-50_runtime-5m_scale-5_summary.csv... Number of rows: ┌─count(bundle_hash)─┐ 1. │ 177985 │ └────────────────────┘ Aggregated statistics: ┌─────────────avg_us─┬─p50_us─┬─p90_us─┬─p99_us─┬─p999_us─┬─min_us─┬─max_us─┬────────────corr_tp─┬─────────avg_size─┬─p50_size─┬─p90_size─┬─p99_size─┐ 1. │ 44447.021855774365 │ 44004 │ 44008 │ 48994 │ 132006 │ 44003 │ 220050 │ 0.1461330024788131 │ 6894.85476304183 │ 2966 │ 19376 │ 40390 │ └────────────────────┴────────┴────────┴────────┴─────────┴────────┴────────┴────────────────────┴──────────────────┴──────────┴──────────┴──────────┘ Bandwidth usage data: ┌─host─────┬─upload_total_MB─┬────upload_avg_Mbps─┬─upload_peak_Mbps─┬─download_total_MB─┬──download_avg_Mbps─┬─download_peak_Mbps─┐ 1. │ 10.0.0.3 │ 2692.634864 │ 104.56834423300971 │ 930.272048 │ 127.1639 │ 4.938403883495146 │ 23.797824 │ 2. │ 10.0.0.4 │ 127.1639 │ 4.938403883495146 │ 23.797824 │ 2692.634864 │ 104.56834423300971 │ 930.272048 │ └──────────┴─────────────────┴────────────────────┴──────────────────┴───────────────────┴────────────────────┴────────────────────┘ ``` Or flamegraphs like: Flamegraph image We won’t go into all the information that is displayed here, but it allowed us to identify processing bottlenecks, bandwidth usage, and network latencies. It also offers a robust e2e testing environment that can be very realistic, and it helped us uncover a lot of bugs and implementation differences with the v1 proxy. However, there are some important caveats to Shadow that we’ve outlined in a section below. ### Stage 2: Live testing Once we got to feature parity with the v1 proxy, and understood the initial CPU profiles and networking results, it was decided to move to live testing. Simulation can only get you so far. Because of some Shadow limitations that we discuss below, and the fact that real orderflow proxies run inside of [TDX](https://buildernet.org/docs/orderflow-sharing-confidentiality), realistic measurements would need to be obtained in live environments. Two existing BuilderNet instances were upgraded to use FlowProxy. The rest of the analysis was all done using real metrics from the live deployment. ## Findings Below we’ll talk about some of the initial findings encountered. Note that performance was not the main goal of this project, but there were some low hanging fruits that we’ll talk about below. ### Signature Verification In stage 1, looking at some profiling results in the form of flamegraphs, it was immediately obvious that ECDSA signature verification was claiming the bulk of CPU cycles. This was true for both ingress proxies (who directly receive orders from users), and receiving proxies (who get forwarded these orders by the ingress proxies). In addition, ingress proxies that had to forward data also spent quite some time on hashing and signing messages. Signature verification happens in roughly 3 places: 1. Verifying the `X-Flashbots-Signature` header when receiving orders from users (on the user endpoint); 2. Verifying the signature on the transactions present in bundles as part of the bundle validation process → Greatly amortised with a cache, see below; 3. Verifying the `X-Flashbots-Signature` header when receiving orders from other proxies (on the system endpoint). Flamegraph image CPU profile on an ingress-only proxy: blue box is `X-Flashbots-Signature` header verification, purple box is transaction signature verification. Together they account for roughly 40% of CPU time spent. Verifying the signature headers on user requests is absolutely necessary to authenticate and score users, but could we decrease the other types of verification? Turns out, when looking at real data, that most bundles contain duplicate transactions (up to 90% in some deployed instances). As such, we can store the result of transaction signature recovery (the signer) in a small cache that maps transaction hashes to signer addresses, and do a very cheap lookup on any incoming transactions to reuse the verification result. By doing this **we have observed instances on Mainnet showing a transaction signature recovery cache hit ratio of up to 70%**, meaning that *time spent validating transaction signatures in bundles has been decreased by the same amount*. Cache hit ratio Proxy v2 running with transaction signature cache. For this instance we have that 68% of the requests received contain already processed transactions for which we can skip signature recovery. TTL applied is 36s. Verifying system signatures (point 3 above) is another issue that we haven’t addressed yet. We’ll revisit this in the next steps section below. ### Freebies by Rust Some metrics improved quite drastically by just switching to Rust. We’ll focus on some of the network-related latencies in the graphs below: **Cross-region RPC** These 2 graphs show the P99 of cross-regional RPC call durations for the v1 and v2 proxies. The first pair is small requests, and the second pair is for big requests (> 50KB). Graph Proxy v1, small requests (< 50KB). Range over 2 days: **137ms** **→ 3.6sec** Graph Proxy v2, small requests (< 50KB). Range over 2 days: **85ms → 2sec** Graph Proxy v1, big requests (> 50KB). Range over 2 days: **260ms → 2.8sec** Graph Proxy v2, big requests (> 50KB). Range over 2 days: **85ms → 2sec** ## Lessons Learned ### Shadow & Simulation Since Shadow is a discrete-event *simulator*, it has a notion of time that is purely tied to network events. If no network events are sent, time (from the perspective of your app, since Shadow intercepts timings syscalls as well) does not progress. That means you can’t use Shadow to accurately measure *processing latencies*, since Shadow won’t progress the time your app sees on compute only. Additionally, micro-benchmarking a single connection by, for example, comparing different transport protocols such as HTTP or gRPC, will also not give you the most accurate results. This is because the simulated networking stack is also completely written from scratch to be deterministic, and the way TCP is implemented is not exactly the same as it would be in the Linux kernel, for example. One very real example here is TCP send & receive buffer sizes, or congestion control protocols, both of which can have major impacts. In Shadow, these are not as configurable as in the Linux kernel. If we were to do this again, we would most likely use something like [`tc netem`](https://www.man7.org/linux/man-pages/man8/tc-netem.8.html) on Linux to *emulate* networking (instead of *simulating*), which reuses the kernel’s networking stack. In fact, the ease of setting up and running scenarios with Shadow, combined with the realistic emulation of `tc netem` would be a great project if anyone is interested. ### **HTTP Connection Pools** The v1 proxies use JSON-RPC over HTTP to forward orders to each other, which is something v2 also does for backwards compatibility reasons. When initially running the Go proxy in Shadow, we noticed that we couldn’t saturate even 1% of the capacity of the simulated link (1 Gbps). After looking into it a bit further, the culprit was a disabled HTTP connection pool, which resulted in the proxy only using 1 underlying TCP connection to send data over. Since HTTP 1.1 (which is what’s currently used) does not pipeline or multiplex, it supports only 1 inflight request per TCP connection! This obviously throttles throughput enormously, especially on long RTT links (which was the case at 88ms RTT). After enabling a connection pool of 300 connections, we were able to almost saturate the bandwidth of the link, and the issue was solved. This was something we kept in mind for the Rust proxy, where the HTTP client library we used was [reqwest](https://github.com/seanmonstar/reqwest). Reqwest, by default, does not allow a lot of granular control over underlying connection pools, and in fact will open 1000s of connections if it needs to, exhausting the system of underlying ephemeral ports. This happened occasionally when incoming requests (and thus forwarding requests) spiked, and we had to introduce functionality to put a hard cap on the amount of available connections. However, it was still noticed that there were a *lot* of connection attempts on the server side of any proxy, many more than before. Additionally, the number of timeouts increased drastically, which would result in lost orders if not dealt with. Graph of connection attempts Connection attempts from other proxies on the server side. Spot the upgrade. Connection attempts from other proxies on the server side. Spot the upgrade. After some digging, this turned out to be an issue with the connection pool, and specifically, how connections are reused. Connection pool strategies determine which idle connection is picked up to handle a new request, and for practical purposes can be reduced to either FIFO (oldest idle connections are reused first), or LIFO (most recent connections are reused first). We knew the Go HTTP library used was FIFO. Reqwest, on the other hand (and the `hyper` connection pool under the hood), uses LIFO. To recap, this means that new requests are routed to connections that are still “warm”, i.e. have recently been used. In theory, this is fine, and might even result in better performance in some cases due to better CPU cache utilization. *However*, in a bursty environment, with a slightly misconfigured client, this can result in a lot of cascading failures. With LIFO, a parameter that becomes very important is the **idle timeout**, an option that can be set on both HTTP server and client. If the server observes a connection that has been idle for more than `X` seconds (10, in our case), it will close the HTTP session. The problem is that if the client doesn’t close the connection at the same time or earlier, it can only *know* the session is closed by sending a request to the server! Imagine the following scenario: 1. OF proxy is in a state where it's happily routing requests over N \<\< 512 (the limit) TCP connections. These are the "warm" connections that continually get re-used. 2. A burst of requests come in, and the HTTP client has to start re-using some of the "cold" connections that were opened previously, because current connections are all unavailable. 3. *However*, because these have become stale (i.e. closed by the server’s 10-second idle timeout), our client quickly bursts requests over all of these stale connections, that in a cascading way get closed for new connections to open, which takes time and may result in timeouts and connection bursts. This issue was fixed by increasing the idle timeout on the server to 30s, and ensuring every HTTP client is configured with an idle timeout slightly less than that. The results can be seen on this server panel: Graph of connection attempts after In addition, the HTTP timeouts for long RTT links also decreased since then: image.png As well as p99 round-trip latencies on those links: Graph of p99 latency Y-axis is logarithmic, p99 dropped from ~125ms to ~95ms. The p99 improvement can be attributed to the decrease in these cascading connection failures, which always resulted in a higher RPC latency (since connections needed to be tried, failed, and re-established). ## Next Steps The main goal of this project was to get FlowProxy production ready and deployed. We believe there are still a lot of improvements that can be made to reduce both processing and networking latencies, but this will require extensive profiling and benchmarking in live environments, where we have the realistic overhead of TDX and data center network links. Two potential high-impact improvements come to mind: ### mTLS In the flamegraphs above, we talked about a lot of time being spent doing authentication on the system API, which is used for intra-BuilderNet communication, through signature generation *and* verification. This is needed because the system API is a public endpoint, which requires authentication. Note that HTTP clients inside of the proxies already authenticate servers (other proxies) through publicly known TLS certificates. If we could use that same mechanism to authenticate clients to servers too, we wouldn’t need signature-based authentication at all on the system API! Mutual TLS (mTLS) is a standard that solves exactly this. It allows the client to attach a certificate to their connection requests, which the server can then authenticate to allow for mutual authentication. Once authenticated, anything sent over this connection is authenticated by default, which means we can get rid of the signing and signature verification. Since system API authentication takes up a large chunk of CPU time in the proxy, this would drastically improve efficiency and tail latencies. ### Replace or Upgrade HTTP As we saw above, HTTP can cause a lot of headaches, especially in high-load environments like this. The fact that HTTP1.1 does not allow pipelining makes it extremely wasteful with connections, so the first step would be changing to something that allows pipelining / multiplexing. HTTP2 / HTTP3 are options here. However, we think that messaging libraries that are even more lightweight, and tuneable for different connection types (long links, short links, high bandwidth) are more suitable (request multiplexing is still a requirement). [Zeromq](https://github.com/zeromq/zmq.rs), [Nanomsg-nng](https://github.com/nanomsg/nng), or our own [msg-rs](https://github.com/chainbound/msg-rs). Switching to a streaming communication pattern (instead of request / reply in HTTP) seems more suitable here. *** On a final note, we want to thank Flashbots engineers for helping us debug and monitor live deployments. We hope to report back soon with the results of upcoming improvements. # Launching Taikoscope Today, we are launching Taikoscope, a monitoring and analytics platform for Taiko Alethia, the first based rollup built on Ethereum. ## Why Taikoscope? Sequencers are currently whitelisted, and Taiko can only partially trust the operators to meet network standards, so independent monitoring is essential. Without visibility, users, the sequencer teams, or Taiko cannot verify performance, diagnose incidents, or quantify economic risk. Taikoscope is a unified dashboard that turns L1 and L2 data into objective alerts and signals across network resilience, security, and value flows in the Taiko ecosystem. Taikoscope gathers live data from Ethereum and Taiko. This data is processed, aggregated, and presented in intuitive dashboards. With Taikoscope, users can: * Monitor network uptime * Compare sequencer performance * Analyze sequencers economics Taikoscope is part of our work on based rollups with two goals: improving observability and analytics, and enhancing usability through better UX and expanded features. ## What Does Taikoscope Track? ### Uptime Monitoring Uptime status Uptime monitoring tracks critical operational metrics to ensure the network remains responsive and reliable. Incidents are created when metrics breach thresholds and shown at [status.taiko.xyz](http://status.taiko.xyz/) **Key Metrics:** * **Transaction sequencing:** L2 blocks are produced regularly * **Batch submissions:** L2 blocks are batched and posted as blobs * **Proof submission:** Proofs are generated * **Proof verification:** Proofs are verified * **Public API uptime:** Availability of the official public RPC **Why It Matters:** Monitoring uptime metrics ensures network reliability, enables rapid incident response, and maintains community trust through transparent, measurable performance. ### Sequencer Economics Sequencer economics Economic metrics analyze revenue and costs for batch submissions and sequencer operations. **Key Metrics:** * **Revenue from base and priority fee:** Income from user transactions on Taiko * **L1 posting costs:** Expenses incurred when posting data to Ethereum * **Network net profit/loss:** Economic viability of sequencers * **Sequencer profit ranking:** Detailed information and comparison of sequencer profits **Why it matters:** Monitoring sequencer economics informs strategic decision-making, helps maintain profitability, and ensures long-term sustainability. ### Sequencer Performance Sequencer performance Performance metrics offer detailed, comparative insights into the efficiency and reliability of sequencers across the network. **Key Metrics:** * **Transactions per second (TPS):** Real-time transaction processing rate * **L2 block cadence:** How often Taiko blocks are created * **Batch posting cadence:** How often batches of L2 blocks are posted to L1 * **Gas usage:** Efficiency in gas consumption per transaction/block * **Tx count per L2 block:** How many transactions are included in each Taiko block **Why It Matters:** Analyzing sequencer performance helps identify bottlenecks, optimize sequencer usage, and incentivize improved network efficiency. ### Network Health Network health **Key Metrics:** * **Prove time:** Time it takes to prove batches posted on L1 * **Verify time:** Time it takes to verify batches posted on L1 * **L2 reorg frequency and depth:** Stability and consistency of blockchain data * **Failed proposals:** If a sequencer creates an L2 block but is unable to include it in a batch and another sequencer has to pick up the work * **Slashing events:** If a sequencer fails to honor their commitments * **Forced inclusions:** Transactions that have been forced directly on L1 **Why It Matters:** Monitoring network health is essential for maintaining stability and community confidence. Tracking these metrics helps quickly detect and mitigate issues, ensuring robust and trustworthy infrastructure. ## Architecture Architecture Taikoscope subscribes to events from both L1 and L2 RPCs, and on each L1 head, and performs the needed contract calls. Data is inserted into ClickHouse and queried from the Dashboard via an API. Taikoscope is developed entirely in Rust, aligning with Chainbound’s performance-oriented engineering ethos. 🔗 **Dashboard**: [taikoscope.xyz](https://taikoscope.xyz/) 🧑‍💻 **Code**: [github.com/chainbound/taikoscope](https://github.com/chainbound/taikoscope) Taikoscope is funded through Taiko’s grant program and MIT licensed. Contributions are welcome! # Linkem: Building a Rust Network Emulator from Scratch ## Introduction When we started working on [`msg-rs`](https://github.com/chainbound/msg-rs) we ran into the necessity of emulating real networks with latency, jitter, packet loss, and bandwidth constraints. We needed a way to test under realistic conditions without setting up actual distributed infrastructure. **Linkem** is what we ended up building. It's a Rust library that creates isolated network peers using Linux namespaces and injects network impairments between them, fully leveraging the kernel TCP/IP stack for modelling traffic. This article documents how it works and the decisions we made along the way. ## Existing Tools and the Gap Before building something new, we looked at what already existed. Network emulation is a solved problem in many contexts, depending on your requirements. **tc** is the foundation everything else builds on. The Linux kernel's traffic control subsystem can add delay, loss, bandwidth limits, and more to any network interface. The problem is that it operates at the system level. You run `tc` commands to modify interfaces, and those changes affect everything using that interface. There's no isolation between tests, no easy way to create multi-peer topologies, and integrating it into a Rust test suite means shelling out to external commands. [**Mininet**](https://github.com/mininet/mininet) and its fork [**Containernet**](https://containernet.github.io/) are Python-based network emulators popular in SDN research. They can create complex topologies with switches, routers, and hosts. Containernet extends this to use Docker containers as hosts. These are powerful tools, but they require defining your topology upfront in Python, spinning up the emulated network, and then running your tests inside it. The workflow is "create topology first, then run code inside" — the opposite of what we wanted for Rust integration tests. [**Toxiproxy**](https://github.com/Shopify/toxiproxy) from Shopify takes a different approach: it's a TCP proxy that sits between your application and its dependencies. You configure "toxics" (latency, timeouts, bandwidth limits) via a REST API. It's great for testing how your app handles a flaky database connection, but it's not network emulation — it's application-layer proxying. Your code has to connect through the proxy, which means changing connection strings. It also can't simulate things like packet loss at the IP level or test UDP-based protocols. [**Estimator**](https://github.com/commonwarexyz/monorepo/tree/main/examples/estimator) from Commonware is a great tool for testing prototypes of consensus protocols, simulating peers distributed across specified AWS regions with real-world latency/jitter in virtual time. It features a handy DSL to create different scenarios and replay them deterministically. Its main downside is the limited scope: it's designed for consensus protocols, not general-purpose network testing. [**Pumba**](https://github.com/alexei-led/pumba) is a chaos testing tool for Docker containers. It can kill containers, pause them, or use `tc` to inject network faults. But it operates on running containers from the outside — you define your Docker Compose setup, start it, then point Pumba at containers to disrupt. Like Containernet, the topology exists before your test code runs. [**netsim**](https://github.com/canndrew/netsim) is the closest to what we needed. It's a Rust library that uses Linux namespaces to isolate network stacks and lets you run async code inside them. However, it doesn't provide the per-destination impairment control we needed. When Peer A sends to Peer B versus Peer C, we wanted different latency and loss characteristics — netsim's model didn't support that out of the box. ### Using the Kernel’s TCP/IP stack There's another issue with most of these tools: they operate at different layers of the stack and aren't true kernel-level network emulation. This matters when you want to test things like: * *TCP buffer tuning* (`tcp_rmem`, `tcp_wmem`) — how do different buffer sizes affect throughput on high-latency links? * *Window scaling* — is your system correctly configured for leveraging TCP window scaling on high bandwidth-delay product links? * *Congestion control algorithms* — how does BBR compare to CUBIC under packet loss? You can't answer these questions with application-layer proxies. The traffic never goes through the real kernel networking stack in a meaningful way. What we wanted was an environment as realistic as possible, where the kernel's TCP implementation, buffer management, and congestion control are all part of the test. To recap, existing tools either they require external setup (Docker, Python scripts, CLI wrappers), they model a single degraded link rather than a topology with per-peer-pair impairments, or they don't operate at the kernel level. For e2e tests and benchmarks where you want real kernel networking behavior, there wasn't an obvious solution. ## Usage The core abstraction is simple: create a network, add peers, define impairments between them, and run async code inside each peer's isolated namespace. Add the library to your project: ```bash cargo add linkem ``` And then model your network with it! ```rust use linkem::*; use std::net::{IpAddr, Ipv4Addr, SocketAddr}; #[tokio::main] async fn main() -> eyre::Result<()> { let subnet = Subnet::new(IpAddr::V4(Ipv4Addr::new(10, 100, 0, 0)), 16); let mut network = Network::new(subnet).await?; // Add peers, each gets its own network namespace and IP let frankfurt = network.add_peer().await?; let tokyo = network.add_peer().await?; let new_york = network.add_peer().await?; // Define realistic cross-region conditions network.apply_impairment( Link(frankfurt, tokyo), LinkImpairment::new() .latency_ms(120) .jitter_ms(5) .loss_percent(0.1) ).await?; network.apply_impairment( Link(frankfurt, new_york), LinkImpairment::new() .latency_ms(40) .bandwidth_mbit(100.0) ).await?; // Run your distributed system network.run_in_namespace(frankfurt, || async { // This code sees frankfurt's network stack. // Connections to tokyo experience 120ms latency. // Connections to new_york experience 40ms latency. start_consensus_node(config).await }).await?; // ... Start other nodes as well } ``` Impairments are directional and per-link. Frankfurt to Tokyo can have different characteristics than Tokyo to Frankfurt. You can model asymmetric links, regional variations, or specific failure scenarios. ### Testing Distributed Systems You can reproduce specific scenarios from production by replaying with comparable request volumes and network conditions, then iterate quickly on improvements. You can also verify message delivery guarantees before deploying to a live environment: * *At-least-once delivery*: ensure consumers are idempotent and can safely handle duplicates. * *At-most-once delivery*: confirm messages are not retried unexpectedly under transient failures. * *Ordering guarantees*: observe how your system behaves when messages arrive late or out of order. * *Eventual consistency*: verify convergence after partitions heal or delayed messages are delivered. ### Chaos Testing Impairments are dynamic, as you can change them at runtime without recreating the network: ```rust // Start with good conditions network.apply_impairment(Link(a, b), LinkImpairment::default()).await?; // Degrade the link mid-test tokio::time::sleep(Duration::from_secs(10)).await; network.apply_impairment( Link(a, b), LinkImpairment::new().latency_ms(500).loss_percent(10.0) ).await?; // Restore it tokio::time::sleep(Duration::from_secs(10)).await; network.apply_impairment(Link(a, b), LinkImpairment::default()).await?; ``` This lets you test how your system responds to transient network issues, degradation during operation, or recovery from failures. ### Case Study: Bandwidth-Delay Product and TCP Tuning One example that shows why kernel-level emulation matters: testing TCP throughput on high-latency links. TCP throughput is fundamentally limited by the [bandwidth-delay product](https://en.wikipedia.org/wiki/Bandwidth-delay_product) (BDP), which we’ve grappled with in a [previous post](https://engineering.chainbound.io/flowproxy-approaching-optimality#kernel-parameter-tuning). On a 10 Mbit/s link with 40ms RTT, the BDP is about 50 KB. If TCP's receive window is smaller than the BDP, you can't fill the pipe — you'll get less throughput than the link can handle. The advertised default receive window starts small and grows dynamically via TCP receive buffer autotuning, bounded by `tcp_rmem` and `rmem_max`, and may exceed 64 KB only if window scaling is enabled. This is controlled by kernel parameters: `tcp_window_scaling` enables the feature, and `tcp_rmem` sets the buffer sizes. With `linkem`, we can actually play around with these settings. Set up a 10 Mbit/s link with 40ms RTT, then: 1. **Disable window scaling, use 64 KB max buffer**: Transfer throughput is limited — TCP can't keep enough data in flight to fill the pipe. ```rust network .run_in_namespace(receiver, |_| { Box::pin(async { // Disable window scaling std::fs::write("/proc/sys/net/ipv4/tcp_window_scaling", "0").unwrap(); // max 64KB std::fs::write("/proc/sys/net/ipv4/tcp_rmem", "4096 16384 65535").unwrap(); }) }) .await? .await?; ``` 2. **Enable window scaling, use 4 MB max buffer**: Throughput jumps significantly, approaching the link's capacity. ```rust network .run_in_namespace(receiver, |_| { Box::pin(async { // Enable window scaling std::fs::write("/proc/sys/net/ipv4/tcp_window_scaling", "1").unwrap(); // max 4MB std::fs::write("/proc/sys/net/ipv4/tcp_rmem", "4096 262144 4194304").unwrap(); }) }) .await? .await?; ``` [In this example](https://github.com/chainbound/msg-rs/blob/main/linkem/examples/bdp_throughput.rs), you can see the difference in measured throughput. And the test runs against the real Linux TCP stack, with real kernel buffer management. You can tune `tcp_rmem` in one namespace without affecting others, given each namespace has isolated `sysctl` parameters. ``` Running `/Users/birb/oss/msg-rs/target/debug/examples/bdp_throughput` === BDP Throughput Demo === Link: 10 Mbit/s, 40 ms RTT, BDP = 50 KB Transfer: 20 messages × 256 KB = 5 MB Test 1: Window scaling OFF, max rwnd = 64 KB Transfer elapsed: 6.715359154s Throughput: 6.2 Mbit/s (62%) Test 2: Window scaling ON, max rwnd = 4 MB Transfer elapsed: 4.609455782s Throughput: 9.1 Mbit/s (91%) Window scaling + larger buffers improved throughput by 46%! ``` ## How It Works Under the hood, Linkem creates a hub-and-spoke network topology using Linux namespaces: Each peer lives in its own network namespace with a virtual ethernet pair connecting it to a central bridge. Traffic control rules on each peer's interface apply impairments based on destination IP — so Peer 1 can have different latency to Peer 2 versus Peer 3. The `tc` configuration uses a hierarchy of queue disciplines (qdiscs): * *DRR (Deficit Round Robin)*: the root qdisc that classifies packets by destination IP, routing each flow to its own class * *TBF (Token Bucket Filter)*: enforces bandwidth limits using a token bucket algorithm * *netem*: adds delay, jitter, packet loss, and duplication This is all managed through direct netlink socket communication — no shelling out to `tc` commands. ### Previous implementation The current `linkem` is actually a rewrite. The first version was a wrapper around `tc` and `ip` shell commands — it could create namespaces and apply impairments, but the implementation was brittle. Shelling out meant parsing text output and debugging failures through command-line error messages. The code was hard to extend and the developer experience suffered. That version also supported macOS via `pfctl` and `dnctl` (the BSD packet filter and dummynet). While cross-platform support sounds nice, maintaining two completely different implementations with different capabilities split our focus. Neither platform got the attention it needed. For the rewrite, we made two key decisions: Linux-only, and direct netlink communication. Dropping macOS let us focus on one platform and ship something more polished. Using netlink instead of shell commands gave us programmatic control over the kernel's networking stack. We build on the [`rtnetlink`](https://docs.rs/rtnetlink) crate for standard operations and construct custom netlink messages where needed. The result is more modular, easier to debug, and a software for which we have more awareness and control. ## Impairment Options Each link can be configured with: | Parameter | Unit | Description | | ----------- | --------- | -------------------------------------- | | `latency` | ms | Base propagation delay | | `jitter` | ms | Random variation added to latency | | `loss` | % (0-100) | Packet loss percentage | | `duplicate` | % (0-100) | Packet duplication percentage | | `bandwidth` | Mbit/s | Rate limit | | `burst` | KiB | Burst allowance for bandwidth limiting | Latency and jitter model propagation delay i.e., the time it takes packets to travel the link. Bandwidth limiting models link capacity with a token bucket filter. These can be combined to simulate various network conditions: a satellite link (high latency, moderate bandwidth), a congested datacenter link (low latency, bandwidth constrained), or a flaky mobile connection (variable latency, packet loss). ## Limitations * **Linux only.** The implementation uses namespaces, netlink, and tc. No macOS or Windows support planned for now. * **Root required.** Creating and mounting namespaces `CAP_NET_ADMIN` and `CAP_SYS_ADMIN` ## Closing Notes `linkem` is still in alpha — the API and ergonomics are evolving as we use it ourselves and learn what works best. If you're building distributed systems in Rust and need realistic network testing, we'd love for you to try it out. We're especially interested in feedback on: * **API ergonomics**: Is the interface intuitive? What would make it easier to use? * **Compatibility**: We've tested on a limited set of kernel versions and distributions. If you run into issues on your setup, let us know: edge cases with different kernels, or minimal Linux environments are exactly what we need to hear about. Check out the [API documentation](https://docs.rs/linkem) or open an issue on the [GitHub repo](https://github.com/chainbound/msg-rs). Suggestions, bug reports, and contributions are all welcome. # Payflow: An Exploration of Agentic Commerce **TLDR**; We’re releasing Payflow, a toolkit and an exploration of agentic commerce through an MCP server powered by [Cryo](https://github.com/paradigmxyz/cryo) and [Reth](https://github.com/paradigmxyz/reth), with paid tools using [x402](https://www.x402.org/) micropayments. Check out the demo [here](https://cryo-mcp.fly.dev/) and the code [here](https://github.com/chainbound/payflow). ## Introduction At Chainbound, we’ve been using agentic tools for engineering and research work for a while now. Our AI arsenal includes integrated tools like Claude Code, Codex, Cursor and Deep Research, but more and more it’s also starting to include various [MCP](https://modelcontextprotocol.io/introduction) servers. [Exa](https://docs.exa.ai/examples/exa-mcp) is a good example: it can supercharge the web search capabilities of your agents by including specialized tools like `research_paper_search`**,** `company_research` and `github_search`. One annoyance right now, and potentially serious bottleneck in the future, is that each of these different MCP servers requires you to register for an account (using your human credentials), and then either sign up for a monthly subscription or load up on credits. Imagine your AI agent needs to analyze a competitor's GitHub repositories (using Exa's GitHub search), fetch real-time market data (via a financial data MCP), and then cross-reference patent filings (through a legal research MCP). Today, this requires you to manually sign up for three services, manage three sets of credentials, and probably pay three monthly fees. Looking at the future, we believe a more scalable solution will need to come into place, as tens of thousands of useful MCP servers will be supplying your agent with different tools and resources, or potentially access to *other AI agents.* [Smithery](https://smithery.ai/?q=is%3Aremote), an MCP registry, shows us we’re actually not that far from that situation, with hundreds of remote MCP servers. MCP is [claimed to tackle](https://humanloop.com/blog/mcp) what’s known as the “M×N integration problem”, which refers to the exponential complexity involved in connecting **M agents** to **N integrations** if they all follow a different protocol. The standardization that MCP brings makes this true on a technical level, but if each MCP integration requires human involvement, *are we reaching the full capacity of autonomous agents?* \[1] In that world, there are a couple of things that don’t work in the flow we described earlier, and they are closely interrelated: 1. **Identity**: using traditional authentication, authorization, and potentially KYC methods, which are *always* tailored towards humans and require humans to be in the loop. This will also be a problem on the receiving side of the payments. For example, try setting up a Stripe merchant account as an AI agent, and you will certainly run into problems. 2. **Payments**: human-focused, clunky and potentially inefficient payment methods such as prepaid credits or monthly subscriptions. On top of that, small, pay-per-use payments are [too](https://www.reddit.com/r/stripe/comments/1bno93e/comment/kwn2hxi/?utm_source=share\&utm_medium=web3x\&utm_name=web3xcss\&utm_term=1\&utm_content=share_button) expensive using traditional rails. Interestingly, cheap blockchains allow you to tackle both at the same time. Blockchains allow anyone (including machines) to create and own a non-custodial wallet, that gives them a unique global address on which to receive transactions as an (autonomous) merchant. As an AI consumer, the corresponding private key can be used to authorize transactions from that wallet. An address becomes a universal API key. \[2] With smart contract wallets, developers can encode arbitrarily complex spending rules & authorization methods for their agents, enforced by the contract itself. The agent could be assigned a passkey to sign transactions with, that only allow it to spend a certain amount through various rate limits. Additionally, stablecoin-based micropayment protocols like Coinbase [x402](https://www.x402.org/) make it much easier to create and accept stablecoin payments without any human involvement. This is why we felt the timing was right to explore this paradigm further. ## What is Payflow? Payflow is our interpretation and example of agentic commerce. It includes 3 things: * An SDK for building paid MCP servers * A local MCP server for your agent to create payments * A demo Payflow-enabled MCP server that puts the 2 together ### `payflow-sdk` A TypeScript SDK that seamlessly extends [`@modelcontextprotocol/sdk`](https://github.com/modelcontextprotocol/typescript-sdk) with the ability to register paid tools using the x402 payment schema. Check out the code and documentation on Github: [`@chainbound/payflow-sdk`](https://github.com/chainbound/payflow/tree/main/packages/payflow-sdk). Adding a paid tool using the `payflow-sdk` is as easy as: ```typescript import { PayflowMcpServer } from '@chainbound/payflow-sdk'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { z } from 'zod'; // Create a server const server = new PayflowMcpServer( { name: 'my-paid-server', version: '1.0.0', }, { // Configure x402 x402: { version: 1, keyId: process.env.CDP_API_KEY_ID, keySecret: process.env.CDP_API_KEY_SECRET, }, }, ); // Simple paid tool without parameters server.paidTool( 'hello_world', 'Says hello to the world', { price: 0.01, // Price in USDC recipient: '0x1234...', // Ethereum address to receive payment }, async () => { return { content: [ { type: 'text', text: 'Hello, World!', }, ], }; }, ); // Connect transport const transport = new StdioServerTransport(); await server.connect(transport); ``` This will mark `hello_world` as a paid tool, and the Payflow SDK will add the necessary description, arguments and return types to ensure the agent using this tool understands the requirements. The payments are currently settled with Coinbase’s mainnet facilitator on Base. This library is still alpha and we're looking for feedback and feature requests. Some potential improvements we’re thinking about: * Support for [Agent Commerce Kit](https://www.agentcommercekit.com/overview/introduction) ACK-PAY, with multiple different settlement methods and currencies. * Support for custom x402 facilitators. * Extending the library to support paid resources on top of paid tools. ### `payflow-mcp` Giving an agent access to payment credentials directly in its context window is not a good idea. In Payflow, the way we deal with this is a fully private, local MCP server called [`payflow-mcp`](https://github.com/chainbound/payflow/tree/main/packages/payflow-mcp) that specifies one important tool (simplified): ```jsx create_payment(amount, recipient); ``` Only the MCP server holds the credentials, and can be configured with hard limits that make sure the agent doesn’t bankrupt any connected wallets. MCP hosts like Claude always request permission to call a certain tool (unless turning it off), so if required, a human in the loop can still authorize the payments after checking the details. Configuring it in Claude is as easy as putting the following into your `claude_desktop_config.json` file: ```json title="claude_desktop_config.json" { "mcpServers": { "payflow": { "command": "npx", "args": ["@chainbound/payflow-mcp"], "env": { // The private key of the agent's wallet "PRIVATE_KEY": "", // Hard payment limit per query "MAX_PAYMENT_AMOUNT_USDC": "10" } } } } ``` ### `Cryo MCP` An [example Payflow MCP server](https://github.com/chainbound/cryo-mcp) that wraps around [Cryo](https://github.com/paradigmxyz/cryo) and is powered by a Reth archive node. Learn more about setting it up here: [https://cryo-mcp.fly.dev/](https://cryo-mcp.fly.dev/). Because we wanted this to be a realistic display of agentic commerce, we’ve tried to make the MCP server as useful as possible, and are excited about its capabilities. You can find some examples of what it can do in the section [here](https://cryo-mcp.fly.dev/#examples). ## Conclusion Agentic commerce is in its infancy. It’s still unclear what payment rails agents would use, or how authentication & authorization would work. But crypto payments are a serious contender, and new protocols like [x402](https://www.x402.org/) and [Agent Commerce Kit](https://www.agentcommercekit.com/overview/introduction) are showing a lot of promise. In a world where agents autonomously access thousands of tools and resources, and pay other agents for services, one thing is clear: existing mechanisms are insufficient, and cheap blockchains offer an alternative. Payflow attempts to display this on a smaller scale, and we offer some tools and libraries that can help other developers to the same. If you’re interested in iterating on this experiment with us, reach out to [explorations@chainbound.io](mailto:explorations@chainbound.io)! ## Footnotes 1. There’s a good argument to be made that human involvement will still be necessary to give the green light on an MCP integration based on trust, because malicious MCP servers can do a lot of damage. Check out [these posts](https://blog.trailofbits.com/categories/mcp/) by Trail of Bits to get an idea. One way to address this argument would be a verifiable discovery and reputation layer for MCP services and other agents, but that is outside of the scope of this article. 2. Compliance might become more challenging in this paradigm. Compliance workflows might shift from KYC to KYT (Know Your Transaction) or KYA (Know Your Agent), and different identity and authorization schemes will be important. Take a look at [Agent Commerce Kit](https://www.agentcommercekit.com/overview/introduction) for a promising approach to this [problem](https://catenalabs.com/blog/ai-and-money-why-legacy-financial-systems-fail-for-ai-agents), as well as [this article](https://ernstberger.xyz/posts/03_delegate/) from Jens Ernstberger for more context. # Block & Blob Propagation with PeerDAS import { Accordion, Accordions } from 'fumadocs-ui/components/accordion'; TLDR; This article analyzes various key Ethereum network metrics to measure the impact of PeerDAS and increasing blob counts. These metrics include block + blob validation latency, attestation rates, and orphan rates. We find a clear correlation between higher blob counts with PeerDAS, and a negative change in said metrics ([link to results summary](#summary-of-results)), and argue that especially with the competitive and latency-sensitive dynamics of PBS, this may impose artificial blob limits that are below any protocol-defined limits. In response to these issues, we outline a design for a network of supernodes that we want to build to help alleviate some of them. | Does blob count impact: | Block latency? | Attestation rate? | Orphan block rate? | | ------------------------------ | ----------------------------------------------------------------- | -------------------------------------------------------------------- | ----------------------------------------------------------- | | Mainnet w/o PeerDAS (baseline) | [**Yes** across the board](#before-peerdas) | [**Yes** but only for p99](#mainnet-before-peerdas-attestations) | [**Inconclusive** results](#mainnet-before-peerdas-orphans) | | Mainnet w/ PeerDAS-BPO1 | [**Yes** across the board](#with-peerdas-bpo1) | [**Yes** across the board](#mainnet-with-peerdas-bpo1-attestations) | [Not enough data](#mainnet-with-peerdas-bpo1-orphans) | | Hoodi w/ PeerDAS-BPO2 | [**Yes** for blob count ≥ 9](#latency-on-hoodi-with-peerdas-bpo2) | [**Yes** for blob count ≥ 14](#hoodi-with-peerdas-bpo2-attestations) | [**No**](#hoodi-with-peerdas-bpo2-orphans) | ## Introduction **PeerDAS**. Ethereum recently deployed Fusaka, an important milestone of its rollup-centric roadmap, which introduces [PeerDAS](https://eips.ethereum.org/EIPS/eip-7594) as its first set of features enabling data availability sampling (DAS). The concrete goal of PeerDAS is to help the network sustain a much higher number of blobs per block. The network supported a blob (target, max) of (6, 9) before Fusaka’s deployment and is expected to support up to (48, 72) hopefully [within 2026](https://stokes.io/blob-scaling-fyi). PeerDAS heavily modifies blob mechanics; it changes how blobs are created, referenced, disseminated/custodied, and verified. PeerDAS therefore naturally raises trade-offs. **The limitations of DAS**. While validators no longer have to download and verify full blobs thanks to PeerDAS, proposers do however have to perform more computation and have to upload more data. With PeerDAS, blobs are now encoded using Reed-Solomon, resulting in encoded blobs that are twice the size of pre-PeerDAS blobs (128 kB → 256 kB). The block proposer organizes the encoded blobs of a block as a 2D matrix where each blob is a row of the matrix. The matrix is split into 128 columns by the proposer who computes a [KZG commitment](https://github.com/ethereum/consensus-specs/blob/master/specs/fulu/polynomial-commitments-sampling.md) per column and disseminates each column separately to each of the 128 sub-networks of validators. Proposers do have to compute and upload more data per block compared to before PeerDAS, mostly because of Reed-Solomon encoding. This problem will be exacerbated as more blobs are added per block with the rollout of BPOs. Some [notable proposals](https://ethresear.ch/t/22298) for the future of DAS, including [FullDAS](https://ethresear.ch/t/19529) and [FullDASv2](https://ethresear.ch/t/22477), are aiming for 2D encoding which further doubles the overhead due to encoding, hence further worsen the bandwidth cost for builders, proposers, and relays and its impact on dissemination latency for all validators. **Empirical analysis**. This document mainly includes an analysis of the impact of blob count per block on Ethereum thanks to the observations recorded in ethPandaOps’ [Xatu database](https://ethpandaops.io/data/xatu/). Namely, the analysis studies the impact of blob count on (1) **block & blob reception latency for validators**, (2) **successful attestation rate by validators**, and (3) **the probability of a block to not be finalized and to be orphaned instead**. The measurements are made on 1. Mainnet pre-PeerDAS deployment to serve as a baseline with a blob count of `(6, 9)` 2. Mainnet post-PeerDAS and BPO1 hence with a blob count of `(10, 15)` 3. Hoodi post-PeerDAS and BPO2 with blob count of `(14, 21)` **Results \[[summary table](#summary-of-results)]**. **Increasing the blob count incurs a clear worsening of both block latency and attestation rate** on almost all observed networks, both before and after PeerDAS alike. Additionally, we observed a worse tail latency after PeerDAS compared to before, and the tail latency increasingly worsens as the blob count increases. However, increasing the blob count has no noticeable impact on the orphan rate; some results are inconclusive and overall more data is required to have a meaningful conclusion regarding orphan rate. **Proposal:** 🌱 **Seeding network**. Considering the results of the analysis, we propose to add a network of specialized supernodes into the Ethereum network with the goal of speeding up the propagation of blocks, blobs, and blob columns from block proposers (the relays in PBS) to the rest of the network. This added service will act like a training-wheels network, ensuring specialized actors in the PBS supply chain don’t start artificially limiting blob counts due to the risk of including them, as discussed in [this presentation](https://www.youtube.com/watch?v=gmu2222iQjc). ## Blob Count Empirical Analysis The impact of blobs on some of these metrics can already be observed in the Ethereum networks today. The following analysis highlights our main observations related to the number of blobs present per block and shows that there is still room for improvement after PeerDAS’ deployment. ### Analysis Scope This analysis focuses on the following questions. Does increasing the blob count impact: 1. The time required for validators to receive blocks and required blob columns? 2. The ability for validators to attest to blocks by the 4s deadline? 3. The probability of a block to end up orphaned? ### Analysis Structure We study four combinations of networks and forks with four different blob counts: 1. Mainnet under the Pectra fork with a stable network of [20,000 nodes](https://arxiv.org/abs/2511.15388) and supporting a `(target, max)` of `(6, 9)` blobs. This dataset serves as a pre-PeerDAS baseline. 2. Mainnet under the Fusaka fork with PeerDAS and BPO1 activated to support a blob count of `(10, 15)`. We observed a noticeable reduction in noise after BPO1 activation so we opted to not include the data before it, i.e., the first week of Fusaka data. This dataset serves as a stable post-PeerDAS case study. 3. The Hoodi testnet under the Fusaka fork, with PeerDAS, run by [2,000 nodes](https://arxiv.org/abs/2511.15388) with BPO2 activated to support a blob count of `(14, 21)`. This network is less stable and smaller than Mainnet but already has BPO2 activated. As a testnet, Hoodi’s architecture is closest to Mainnet which makes it preferable to study than Sepolia. This dataset serves as a case study with PeerDAS and BPO2. ### Setup **Sources**: All the results are extracted from the Xatu database maintained by ethPandaOps. The exact queries used to obtain the plots are publicly accessible for reproduction in [https://github.com/chainbound/blob-seeder-data](https://github.com/chainbound/blob-seeder-data). **Plots**: Some plots use a box-and-whisker representation to depict the distribution of results. The boxes are classic: the ends of the boxes represent the 25th and 75th percentiles (noted p25 and p75) and the bar inside each box represents the median (p50). The following plots include two pairs of whiskers to better depict tail latency behaviors; the whiskers represent p1 and p5 on one end and p95 and p99 on the other. Outliers are half-transparent colored circles outside of the whiskers’ range. The box-and-whisker parameters are reminded in the top right corner of the plots. ### Summary of Results | Does blob count impact: | Block latency? | Attestation rate? | Orphan block rate? | | ------------------------------ | ----------------------------------------------------------------- | -------------------------------------------------------------------- | ----------------------------------------------------------- | | Mainnet w/o PeerDAS (baseline) | [**Yes** across the board](#before-peerdas) | [**Yes** but only for p99](#mainnet-before-peerdas-attestations) | [**Inconclusive** results](#mainnet-before-peerdas-orphans) | | Mainnet w/ PeerDAS-BPO1 | [**Yes** across the board](#with-peerdas-bpo1) | [**Yes** across the board](#mainnet-with-peerdas-bpo1-attestations) | [Not enough data](#mainnet-with-peerdas-bpo1-orphans) | | Hoodi w/ PeerDAS-BPO2 | [**Yes** for blob count ≥ 9](#latency-on-hoodi-with-peerdas-bpo2) | [**Yes** for blob count ≥ 14](#hoodi-with-peerdas-bpo2-attestations) | [**No**](#hoodi-with-peerdas-bpo2-orphans) | * **Latency: Clear impact of blob count on latency.** * When comparing latency before and after PeerDAS for blocks with the same blob count, the data shows that, even for low blob counts of 3 to 9, **PeerDAS has improved validation latency for 95% of validators on Mainnet compared to Pectra but worsened it for the remaining minority. However, blocks are disseminated faster with PeerDAS than without, which indicates that the dissemination of blob data necessary for validation is actually slower for the last 5% of validators with PeerDAS than without.** * Mainnet without PeerDAS: The latency increases linearly with the number of blobs. This trend covers the entire distribution and is visible from p5 to p99 values. Additionally, p99s are at or above the 4 s deadline for all blob counts; p99 for 9 blobs is even greater than 5 s. * Mainnet with PeerDAS-BPO1: The linearly increasing trend persists with PeerDAS and is visible across the plot from top to bottom for all values from p5 to p99. The latency is slightly improved for most nodes with PeerDAS compared to without since most p50 and p75 values are improved by 100-200 ms. However, the unluckiest 1% of validators experience worse block latency with PeerDAS since the p99 values are all worse for blob count ≥ 3. The p99 values are all greater than 5 s for blob count ≥ 9, greater than 6 s for 14 blobs, and it even reaches 12.4 s for the max blob count of 15. * Hoodi with PeerDAS-BPO2: There is a clear increasing trend for blob count ≥ 9 values that appear mostly linear for all p1 to p99. This indicates that the trend may likely remain linearly increasing on Mainnet once BPO2 is deployed. * **Attestation rate: Clear impact of blob count on attestation rate.** * Mainnet without PeerDAS: A higher blob count increases p99 of failed attestation rate for blob count ≥ 4. The blob count barely impacts attestation rate otherwise. * Mainnet with PeerDAS-BPO1: Failed attestation rate is overall significantly worse with PeerDAS than without. p75 before PeerDAS is stable at 0.6% across all blob counts but it increases from 0.9% to 1.8% as the blob count increases after PeerDAS. Similarly, p95 increases from 2.4% for 1 blob to 7% for 15 blobs. * Hoodi with PeerDAS-BPO2: The failed attestation rate increases linearly with the number of blobs for blob count ≥ 14. The median rate of validators that do not attest finalized blocks goes from 4% for 14 blobs to 5% for 21 blobs, and p99 goes from 11% for 14 blobs to 37% for 21 blobs. * **Orphan block rate: Little to no impact of blob count on orphan block rate.** * Mainnet without PeerDAS: The results are inconclusive. One plot computing the orphan rate based on the total number of slots (the absolute rate) shows patterns indicating a correlation between blob count increases and orphan rate increases. However, the second plot that computes the orphan rate based on the number of finalized blocks containing the same number of blobs — i.e., the proportional rate — does not show any pattern. * Mainnet with PeerDAS-BPO1: Orphans are rare, and there is not enough data yet. * Hoodi with PeerDAS-BPO2: There does not appear to be a correlation between blob count and rate of orphan blocks on Hoodi. The plot showing absolute rate mostly highlights two outlier values, while the plot showing proportional rate mostly highlights the pattern seen on block latency between 6 and 13 blobs. ### Mainnet Latency Comparison CDF of block validation latency **Description**. This plot shows the evolution of the fraction of validators that have received enough data to validate a block on Mainnet with Pectra versus with Fusaka rules when there are 1, 5, and 9 blobs in a block. More precisely, it depicts the cumulative distribution function (CDF) for the block validation latency that we define as the earliest time at which a validator is able to validate a block according to the consensus rules. In order to validate a block, a validator in Pectra must have received the block and all its blob sidecars, while it must only receive the block and at least 8 blob columns, including all the blob columns it must custody, under Fusaka rules. The details of the Pectra and Fusaka datasets — Mainnet before PeerDAS and Mainnet with PeerDAS-BPO1, respectively — are explained further in the description of their respective latency plots. **Takeaways**. All three pairs of lines, when paired by blob count, exhibit a similar pattern: most validators can verify blocks earlier in Fusaka compared to Pectra, i.e., the dashed line is mostly above the solid line. Additionally, adding blobs in a block naturally adds latency. We observe a slight slump in the dissemination around 2.3-2.5 s that may be due to cross-continental latencies between dense clusters of validators. Zoomed in CDF of block validation latency **Description**. This plot is a zoomed version of the above plot, with lines for 3 and 7 blobs added, to highlight the beginning of the tail of the disseminations, past p95. **Takeaways**. The behavior of the pair of lines for 1 blob remains consistent with the above plot: more than 99% of the validators are able to verify blocks faster under Fusaka than under Pectra when the blob count is that low. **However, the behavior is reversed for higher blob counts**: the unluckiest 4-5% validators actually require more time to receive the validation data with Fusaka than with Pectra. The trend worsens as the blob count increases as shown by the gap between the solid and dashed lines of the same color. These two plots show that PeerDAS has improved latency for 95% of the validators but worsened latency for the remaining minority. CDF of blob propagation latency Zoomed in CDF of blob propagation latency **Description**. These two plots mimic the above plots but focus on the latencies for validators to receive enough blob data to validate blocks, i.e., all blobs for each block in Pectra compared to only the required blob columns in Fusaka. These plots do not consider block latencies, only blob column latencies. **Takeaways**. These two plots confirm the previous deduction that most nodes receive blob data faster in Fusaka than in Pectra, that it is the opposite at the tail, for the unluckiest 5%, and that the trend worsens as the blob count increases. #### Before PeerDAS Boxplot **Description**. This plot shows the distribution of block discovery & validation latencies on Mainnet under Pectra (before PeerDAS) depending on the number of blobs referenced in each block. As mentioned above, Pectra requires validators to download all blob sidecars referenced in a block in order to validate it. That latency is visible in the difference between discovery and validation latencies. Each data point of the plot is a unique tuple: (receiving node, finalized block). Block latencies higher than 30s have been filtered out to remove obvious outliers. The dataset is aggregated over the whole month of November 2025. The black vertical line at the 4s mark indicates the block attestation deadline. **Takeaways**. There is a clearly visible increasing trend for all metrics from p25 up to p99, indicating that a higher blob count leads to a higher block validation latency. The p99s are already at the 4s deadline for 0-1 blobs and are above that deadline for all blobs ≥ 2, yet the validators are expected to not only receive but also process the block and its associated blobs by that deadline. #### With PeerDAS-BPO1 Boxplot **Description**. This plot replicates the previous latency plot on Mainnet but after PeerDAS and BPO1 have been deployed. In addition, it shows the *block discovery latency*, as the first box for every level. This makes the added latency purely from blob column propagation more clear. As mentioned above, the validation rules for Fusaka are different and require a validator to not download full blobs but instead at least 8 blob columns, including all the blob columns it requires to custody. The 11-day sampling period ranges from 2025-12-10 00:00 UTC, soon after the activation of BPO1, to 2025-12-21 23:59 UTC. **Takeaways**. The trend appears similar pre- and post-PeerDAS from p5 to p99. Interestingly, p50 values are overall improved by 100-200ms post-PeerDAS, but p99 values are worse for blob count ≥ 3, implying a lower quality of service experienced by the unluckiest 1% of validators. The p99 values are all greater than 5s for blob count ≥ 9, reaching 6.1 s for 14 blobs and even 12.4 s for the max blob count of 15. ### Latency on Hoodi with PeerDAS-BPO2 Boxplot **Description**. This plot depicts block validation latency on Hoodi once PeerDAS and BPO2 have been deployed. BPO2 has been deployed on 2025-11-12 on Hoodi and raised the blob count to (target, max) of `(14, 21)`. The data used for this plot has been sampled over 7 days, from 2025-11-28 00:00:00 UTC to 2025-12-04 00:00:00 UTC, compared to a month for Mainnet because of the much higher volatility observed on the Hoodi testnet. **Takeaways**. The impact of blob count on block latency is not as clear on Hoodi as it is on Mainnet. There is a clear increasing trend that appears mostly linear for blob count ≥ 9 for all p1 to p99 values. We observed that a lot of nodes were not receiving enough blob columns to verify blocks, hence artificially reducing the overall latency because only successful validations are taken into account in the plot. ### Attestation Rates #### Mainnet before PeerDAS Boxplot **Description**. This plot shows the fraction of validators that never attested to a block depending on the number of blobs in that block. Each data point corresponds to a finalized block. For instance, a p99 of 30% means that 1% of the blocks are attested by only 70% of the validators. The data is aggregated over the whole month of November 2025 on Mainnet. The x-axis of the plot is split into two linear axes of different scales to improve the readability of both boxes and whiskers. **Takeaways**. There is a very light increasing trend on the median and a more visible trend on p99 for blob count ≥ 4. The p75 values are all close to or below 0.6% of failed attestation rate, which indicates a stable network even for a blob count of 9. #### Mainnet with PeerDAS-BPO1 Boxplot **Description**. This plot replicates the above attestation rate on Mainnet but after PeerDAS is activated and up until BPO1 activation. The plot is similarly split in two for readability. **Takeaways**. Most importantly, the attestation rate overall worsens: p75 before PeerDAS hovers around 0.6% of failed attestation rate while it goes from 0.9% to 1.8% as the blob count increases once PeerDAS activates. All measurements from p50 up to p99 depict a similar trend: blocks have worse attestation rates as the blob count increases. The attestation rate is much more impacted by the blob count in this plot, with PeerDAS, than in the previous plot, without PeerDAS. #### Hoodi with PeerDAS-BPO2 Boxplot **Description**. This plot replicates the above plot on attestation rate but on Hoodi with BPO2 activated. The data has only been sampled over a week because of the much higher volatility observed Hoodi compared to Mainnet; in some cases, increasing the sample size increases the presence of outliers. **Takeaways**. There is a clear increasing trend starting at 12 blobs where all measures above p25 visibly worsen as blob count increases. Additionally, p99 worsen across the board as the blob count increases. Compared to Mainnet before PeerDAS and its p75 below 1%, the p75 is much worse on Hoodi with PeerDAS and ranges between 5% and 7% instead. ### Orphan Block Rates #### Mainnet before PeerDAS **Absolute** Mainnet before PeerDAS orphan rate **Description**. This plot depicts the orphan block rate categorized by the number of blobs referenced in these blocks. These orphan blocks have been created by the expected proposer of their respective slots but, for unspecified reasons, did not end in the finalized chain of blocks. These rates are computed proportional to the total number of slots in the sampling period: 216,000 slots in November 2025. **Takeaways**. The overall orphan rate is low at 584 / 216000 = 0.27%, which indicates an “uptime” of 99.73% for Ethereum. We observe two patterns in this plot. First, a pattern appears to repeat every 3 rows where a high value for a row is followed by two lower values: 63 orphans for 0 blobs followed by 11 and 1 orphans for the next two rows, 71 orphans for 3 blobs followed by 30 and 22 orphans for the next rows, and similarly for 6-9 blobs. The second pattern shows that increasing the blob count increases the orphan rate but only once we consider the first pattern. The increasing trend is clear when only observing blob counts of 0, 3, 6, and 9. The confidence in this second interpretation is lowered due to our inability to explain the first pattern. **Proportional** Mainnet before PeerDAS orphan rate **Description**. This plot replicates the above absolute orphan rate plot but changes the divisors used in the rate computation. This plot shows “proportional” rates in that they are based on the number of finalized blocks that have the same blob count as the orphan blocks. **Takeaways**. We mainly observe a diverse distribution of blob count in finalized blocks (the divisors in the fraction next to the bars) and two outliers at 2 and 9 blobs. With outliers ignored, the blob count does not appear to impact the orphan rate, unlike in the plot showing absolute orphan rate. #### Mainnet with PeerDAS-BPO1 It would take several weeks to gather enough data to generate a plot for Mainnet post-Fusaka. For comparison, the above plot showing orphan blocks on Mainnet before PeerDAS depicts a small amount of orphan rate despite being computed over a whole month. The data below shows a very small number of observations for the higher blob counts, so is not indicative. The 15 blob outliers do stand out with an orphan rate of >5%. Mainnet with PeerDAS-BPO1 orphan rate #### Hoodi with PeerDAS-BPO2 **Absolute** Hoodi with PeerDAS-BPO2 orphan rate **Description**. This plot depicts the rate of orphan blocks on Hoodi as a fraction of the total number of slots in the sampling period: 50,400 slots in 7 days. **Takeaways**. The overall orphan rate over the sampling period is low at 319 / 50400 (orphans / total slots) = 0.63%, which is marginally higher than the 0.27% of Mainnet. There are two clear outliers, for 0 blobs and 21 blobs, and there does not appear to be any clear trend indicating an impact of the blob count on the rate of orphan blocks in Hoodi. **Proportional** Hoodi with PeerDAS-BPO2 orphan rate **Description**. This plot shows the rate of orphan blocks on Hoodi over a week as a fraction of the number of finalized blocks that have the same blob count. **Takeaways**. There is a notable surge of the orphan rate between 6 and 13 blobs; we have no explanation for this event. The value for 0 blobs is a clear outlier, and the value for 21 blobs can be treated as an outlier due to very high divisor, between 4 and 46x larger than for other blob counts. Once the last row of 21 blobs is discarded as an outlier, there is no clear trend indicating a correlation between blob count and rate of orphan blocks. ## Related Studies * 2024-11: Pre-PeerDAS data showing a trend between block+blob sizes and delivery latencies [\[post\]](https://ethresear.ch/t/block-arrivals-home-stakers-bumping-the-blob-count/21096) * 2025-09: **Ethpandas BPO report** on safety/timely delivery of blobs on Fusaka devnet 5 with up to 30-50 blobs but with a small dataset [\[post\]](https://ethpandaops.io/posts/fusaka-devnet-5-bpo-analysis/) * 2025-09: **EF’s detailed attestation timings analysis** in the post studying the feasibility of 6-second slots [\[post\]](https://ethresear.ch/t/an-analysis-of-attestation-timings-in-a-6-s-slot/23016#p-56048-factors-impacting-late-attestations-7) * 2025-10: Ethpandas report on up/down bandwidth consumption for various types of nodes on Fusaka devnet 5 [\[post\]](https://ethpandaops.io/posts/fusaka-bandwidth-estimation/) * Lots of reports with analyses from Sunnyside Labs [\[reports\]](https://www.notion.so/21d8fc57f546803cb2afd760727c2ff6?pvs=21) * 2025-10-10 [\[report\]](https://www.notion.so/2858fc57f54680d2a700d4ee1f735587?pvs=21) that complements the Ethpandas BPO report [\[post\]](https://ethpandaops.io/posts/fusaka-devnet-5-bpo-analysis/): The number of missed attestation deadlines (the opposite of the “head correctness” metric in the reports) goes up to more than 50% for full nodes as the blob count increases from 10 to 40. Super nodes are mostly fine. The p75 latency for 50-60 blobs per block reaches 3 s so the tail is much higher (unclear in reports). Sampled columns are hard to get with 60+ blobs per block, likely due to network contention: too much bandwidth is required for the execution client and the consensus client struggles to respond to column requests. * 2025-09-30 [\[report\]](https://www.notion.so/27e8fc57f5468093b562f7161d3464f0?pvs=21): Fusaka devnet 5 with 1,700 nodes (1/8 of mainnet): The bottleneck for high blob count is the full node uplink due to the high number of blobs in the mempool (execution client), while the bandwidth for super nodes is mostly in sampled columns (consensus client). * 2025-07-14 [\[report\]](https://www.notion.so/2308fc57f5468054b18fcbff31cc032c?pvs=21): Appendix B for list of Grafana dashboards, Appendix D for some simple avg+max bandwidth on nodes when increasing blob count. ## 🌱 Proposal: Seeding network As highlighted in the analysis, PeerDAS negatively impacts a number of measurements related to consensus stability, especially at the tail. On its own, for small bumps in blob counts with upcoming BPO’s, this shouldn’t be cause for alarm. However, in the PBS supply chain, milliseconds matter. Relays offer services for validators to delay committing to a block for as long as possible, to maximize MEV (timing games). This works because as of today, the latency penalty for including blobs is relatively minimal. As we’ve seen above however, that’s already changing, which may lead some entities in the PBS supply chain to impose artificial ceilings on the blob limit that are way below the *actual* limit. This would counteract the DA scaling benefits that are PeerDAS’ main raison d'être. **What.** Because of this, we propose to build a global network of supernodes whose only task is to accelerate the propagation of data (blocks and blobs) from the block originator (relay in PBS) to the rest of the network. We expect the seeding network to reliably reduce and stabilize the latency experienced by validators by reducing and bounding the number of communication hops required to disseminate data to most validators. It will additionally improve blob usage efficiency by ensuring that builders can reliably include the economically optimal number of blobs without any additional latency penalties. The concept of seeding network is aligned with the [Rainbow staking framework](https://ethresear.ch/t/18683) (albeit out of protocol) as it relies on more powerful supernodes to contribute more to the network than regular nodes, ultimately improving the quality of service for everyone. These supernodes extend the idea of *DAS providers* as described in the [original PeerDAS post](https://ethresear.ch/t/16541#das-providers-13). A [couple](https://notes.ethereum.org/NgKvvYiAQ7WdxPECCVdGUQ) of [designs](https://ethresear.ch/t/21758) have proposed for PBS relays to become DAS providers and to offer an RPC service that validators can query to obtain samples. We propose to extend these designs with an additional support for GossipSub in order for supernodes to proactively accelerate data dissemination instead of only being passive actors waiting for client queries. **How**. This seeding network will be composed of highly performant and highly connected supernodes that will act as network hubs in order to deliver as much data to as many nodes as possible. Supernodes will contribute to the dissemination of block and blob columns by subscribing to the GossipSub topics used for blocks (`beacon_block`) and for the 128 blob column subnets (`data_column_sidecar_[0-127]`). Additionally, supernodes will reply to the relevant RPC requests for blocks, blobs and blob columns, e.g., `BeaconBlocksByRoot`, `BlobSidecarsByRoot`, `DataColumnSidecarsByRange`. The inter-supernode connections will be implemented the battle-tested, highly performant [https://github.com/chainbound/msg-rs](https://github.com/chainbound/msg-rs) that powered our low-latency mempool service [Fiber](https://research.chainbound.io/fiber-vs-bloxroute-the-standoff). The geographical placement of each supernode and the topology of the network will be designed to minimize overall dissemination and request latencies for the validators, ensuring proper distribution in hotspots such as Virginia, Frankfurt and Tokyo. ### Expected Metric Improvements We expect the seeding network to have clear improvements on the Ethereum network in terms of: 1. **Block and blob reception latency**: A small network of high-performance supernodes enables dissemination with fewer hops than a larger and more heterogeneous network does. 2. **Attestation rate and orphan block rate**: Thanks to a faster dissemination of both blocks and blob columns, validators will be able to attest blocks faster, hence reducing the probability that blocks fail to be attested in time and end up as unproductive orphans instead of being part of the finalized chain. 3. **Reduction of strategic blob limiting in PBS**: Because of the above 2 improvements, the benefits of strategic (lower) blob inclusion in the PBS supply chain are nullified. ### ePBS (Glamsterdam) **Source**: [\[high-level description\]](https://ethereum-magicians.org/t/eip-7732-the-case-for-inclusion-in-glamsterdam/24306) **Problem**: A 12 s slot may be too small to do all the downloading and verification that validators do before attesting a block. **Proposal**: Minimize what information is downloaded and verified (i.e., only the consensus block) during the slot that the block is proposed in, and tolerate a delay of up to an additional slot to perform the rest of the download and verification (e.g., blobs, execution payloads). **Expected result**: Better attestation rate, particularly for blocks with high blob count. **Readiness**: Headliner for Glamsterdam. **Compatibility with seeding network**: Compatible but greatly reduces the need to reduce block and blob delivery latency. However, ePBS does not impact the motivation regarding bandwidth or reduced slot duration. ### Sparse blobpool (Maybe Glamsterdam?) **Source**: [\[EIP draft\]](https://github.com/ethereum/EIPs/blob/master/EIPS/eip-8070.md) [\[older ethresearch post with similar ideas\]](https://ethresear.ch/t/is-data-available-in-the-el-mempool/22329) **Problem**: The network bandwidth of the execution client overshadows that of the consensus client when the blob count increases towards high dozens, e.g., 68% of the bandwidth is used by the execution client for blob (target, max) of (48, 72). **Proposal**: Perform blob sampling on the execution client as well, in addition to PeerDAS doing sampling on the consensus client. The sampling done by the two clients is aligned: the execution client queries the network for the columns needed for the consensus client. The probability of sampling a blob could theoretically be set independently by each full node, but the authors propose a single network-wide parameter to ensure enough nodes have full blobs. **Expected result**: By only downloading full blobs 15% of the time and sampling 85% of the time, the execution client reduces by 75% its bandwidth consumption. **Readiness**: Proposed for integration into Glamsterdam [\[list update\]](https://github.com/ethereum/EIPs/pull/10651). **Compatibility with seed network**: Good. Sparse blobpools reduce remote queries for full blobs so a seed network would be less useful but not useless since a lot of blob data is still downloaded. ### Sharded blob mempool **Source**: [\[ethresearch post\]](https://ethresear.ch/t/22537) **Problem**: (1) Blobs downloaded by execution clients incur a high network bandwidth, (2) gossiped data related to blobs received by both consensus and execution clients may be redundant, and (3) consensus clients download complete columns while the execution client may already have a subset of them. **Proposal**: Better synergy between consensus and execution clients by: (1) Sharding the blob mempool such that execution clients only download a subset of it (e.g., 16 in the proposal) and it must be the same rows custodied by the consensus client, (2) having only the execution client disseminate the blob rows, (3) enabling partial column queries to avoid having consensus clients downloading what they already have. The idea behind sharded blob mempool is similar to sparse blobpool in the sense that it makes consensus and execution clients work better together. **Expected result**: Reduced bandwidth and latency required to validate blocks. **Readiness**: High-level description post. **Compatibility with seed network**: Good. The proposal reduces the overall network bandwidth so a seed network would be less useful but not useless. ### Future of DAS **Main source:** [\[Ethresearch summary of various combinations of commitments and coding\]](https://ethresear.ch/t/22298) **Other sources**: [\[FullDAS ethresearch post\]](https://ethresear.ch/t/19529) [\[FullDASv2 ethresearch post\]](https://ethresear.ch/t/22477) [\[a16z post on using Pederson commitments with IPA instead of KZG\]](https://a16zcrypto.com/posts/article/an-overview-of-danksharding-and-a-proposal-for-improvement-of-das/) **Problem**: PeerDAS applies Reed-Solomon coding separately per blob, in what is called 1D coding and sampling, and a future goal is to move towards 2D coding and sampling for greater resilience to blob data loss. The problem is how to best design this second dimension of coding. **Proposal**: FullDAS and FullDASv2 are similar to PeerDAS in that Reed-Solomon + KZG are used for the second dimension as well. The summary post explores how to do cell-level DAS several combinations of commitment schemes — namely Merkle trees, KZG commitments, and Pedersen commitments — and coding schemes — namely Reed-Solomon, random linear codings (RLCs), and random linear network codings (RLNCs). The summary concludes that using KZG commitments may not be the best option for the second dimension due to the high computational cost incurred on every node that forwards the samples, which would reduce propagation at every step. Merkle trees may be the most viable for fast propagation. **Expected result**: Reduce the amount of data required to validate blocks. **Readiness**: Descriptive posts. **Compatibility with seed network**: Perfect. 2D coding and sampling mean more data to transmit and an even slower seeding, making the seed network even more relevant. ### RLNC-DAS by Optimum **Source**: [\[arXiv\]](https://arxiv.org/abs/2509.21586) **Problem**: PeerDAS has a small CPU footprint on the proposers that must perform additional computation and a big networking footprint for proposers and supernodes. **Proposal**: Replace PeerDAS’ mechanisms based on KZG commitments and Reed-Solomon coding that are computed once at block building time with Pederson commitments generated once at block building time and later random linear network coding (RLNC) that is done on the fly by full/supernodes as clients query them for blob samples. RLNC codes are therefore not broadcast on the network; there is no network overhead during broadcast since only the payload of blobs is broadcast. **Expected result**: Much better network bandwidth footprint for sampling clients (10x better than PeerDAS) and zero storage overhead. The main downside is the increased CPU and network footprint for full nodes, which appears to scale linearly with the number of querying clients but is unfortunately not evaluated. **Readiness**: Prototype with unreleased code and public tech report. **Compatibility with seeding network**: Incompatible. RLNC-DAS is completely different from PeerDAS. It is designed to minimize the amount of broadcast data while the proposed seeding network strives for scenarios with a lot of broadcast data. It is compatible with supernodes since the CPU footprint grows linearly on a node with the number of clients that query blob samples. ## Acknowledgments We thank the ethPandaOps team for giving us access to and helping us with the Xatu databases, as well as the Xatu contributors for populating Xatu with data for us to analyze. We also thank the authors of the “Multiple Sides of 36 Coins” blockchain network analysis published at SIGMETRICS 2026 [\[arXiv version\]](https://arxiv.org/abs/2511.15388), and particularly Lucianna Kiffer, for sharing updated numbers on the sizes of various Ethereum networks. # Private API Credits: A Simple Construction ## Introduction Privacy is back on the public radar. Over the past year, multiple prominent players within the Ethereum ecosystem have been [unveiling](https://ethereum-magicians.org/t/a-maximally-simple-l1-privacy-roadmap/23459) [their](https://ethresear.ch/t/ethereum-privacy-the-road-to-self-sovereignty/22115) [privacy roadmaps](https://pse.dev/blog/pse-roadmap-2025). More recently, the Privacy & Scaling Explorations ([PSE](https://pse.dev/)) team have undergone a metamorphosis: they are now the Privacy Stewards of Ethereum. The price of Zcash increased 9-fold over the last year, and shielding protocols like [Railgun](https://www.railgun.org/) have [continued growing in deposited liquidity](https://dune.com/railgun_project/railgun). Despite this rapid growth and renewed attention, a recent [report](https://pse.dev/blog/privacy-experience-report) by PSE shows that overall adoption remains low, with many pain points persisting for more privacy conscious users. One concern of the report stood out to us in particular: a user setting up [Privacy Pools](https://privacypools.com/) locally was confused that they had to configure an Alchemy API key to get the front-end running locally. > There are so many leaks if I’m using Alchemy… what is the point? They have a point. An API key is linked to an account on a certain platform, which contains personally identifiable information (PII). It also surfaces the full chain of requests made by that single account. Having an account is clearly needed for purchasing API credits, as well as potentially any compliance checks. But is the link between an API key and the PII really necessary? Additionally, do we really need a persistent identifier across *all* requests? Can it be avoided so users can remain safe from honest-but-curious providers or data breaches? To answer this question, we’ll need to take a detour and look at a fairly annoying but ubiquitous aspect of browsing the internet: CAPTCHAs. ## CAPTCHAs & Privacy Pass CAPTCHAs were introduced to identify requests coming from humans instead of bots, and are used in website protection services like Cloudflare. This was a necessary service to protect websites from DoS attacks, but it introduced a lot of friction for users. Millions of collective hours were wasted in solving semi-useless puzzles as a proxy for personhood. When [Privacy Pass](https://privacypass.github.io/) was introduced in 2017, Cloudflare was one of its [first adopters](https://blog.cloudflare.com/cloudflare-supports-privacy-pass/). Why? Because Privacy Pass provided a way to drastically reduce the amount of CAPTCHAs users had to solve online, all while maintaining user privacy. This last part is important. Cloudflare could technically issue a persistent identifier to users solving a CAPTCHA, that can be redeemed every time a user is presented another challenge. But that would allow Cloudflare to trivially track the complete browsing history of a user. Not great. The Privacy Pass protocol solves this by leveraging smart cryptography. Instead of a persistent identifier, successful solvers of a CAPTCHA get a set of blinded tokens that are signed by Cloudflare. The magic happens when the user receives these signed, blinded tokens: they can unblind them into a form that is cryptographically unlinkable to the original blinded token, all while the cryptographic signature representing the stamp of approval remains valid! These unblinded tokens are what the user (the Privacy Pass extension to be exact) redeems when they are presented with a challenge, and Cloudflare allows them to bypass the challenge because they see their valid signature on it. To Cloudflare, every token looks completely random, so they can’t track users with this mechanism. ### Privacy Pass for General Authentication In the [Privacy Pass Architecture RFC](https://www.ietf.org/rfc/rfc9576.html), the issuance of tokens happens after a process they call attestation. In this context, an attestation just signifies the fact that you have satisfied the attester. But an attestation can be anything: with CAPTCHAs, your attestation is that you solved a puzzle correctly. With Apple’s [Private Access Tokens](https://developer.apple.com/videos/play/wwdc2022/10077/) (their extension of Privacy Pass), what’s being attested to is the fact that the user is using an Apple device, is logged in to an Apple ID, and is not rate limited. Aside from the lock-in concerns, this is neat because it’s all private. If we generalize the Privacy Pass mechanism, it can be used for *any* form of private authentication, not just CAPTCHA puzzles. The magical property here is the *unlinkability* between the *issuance* of tokens, which happens after a successful attestation, and *redeeming* those tokens. This mechanism is a powerful and private alternative to persistent session identifiers. [Kagi](https://kagi.com/), a subscription-based search engine, realized this as well. [They use Privacy Pass](https://help.kagi.com/kagi/privacy/privacy-pass.html) for exactly this mechanism: allowing authenticated users to execute private, unlinkable searches. An astute reader may have realized where we’re going with this: **this protocol could easily be adapted to retrofit RPC providers with private RPC requests**. It could completely sever the link between signing up and buying credits, and then spending those credits in subsequent requests. RPC providers can still work with their prepaid API credits model, they just won’t be able to correlate any of the requests with the original accounts, or track a single user over time. This would (almost) solve the problem our user was having! We can even design it in a way that users of client libraries barely have to change anything in their workflows, but more on that later. Key takeaway: the same mechanism that replaces repeated CAPTCHAs can replace persistent API keys. ## The Protocol So how does Privacy Pass work? We won’t get into the actual cryptography here, but rather give you a high level overview of the interactions to help you build an intuition. The first iteration of Privacy Pass based on blind signatures is the easiest to understand, so we’ll start there. Blind signatures are usually conceptually explained with [carbon paper](https://en.wikipedia.org/wiki/Carbon_paper). Imagine a voting center where a local community goes to vote. Before shipping off the ballots to a central area where the votes are counted, the local voting center certifier is required to sign (certify) each ballot. Certifying in this case consists of ensuring that the ballot came from a person that was present, and that it’s their only ballot (no double voting). The problem is that voters are not comfortable sharing their ballots with the certifier, because they don’t trust him not to look at the vote while signing. Someone comes up with a system to address this: 1. Put a piece of carbon paper on top of the ballot, wrap it in an envelope, and seal the envelope. 2. Give the envelope to the certifier who will authenticate you and then sign the envelope. Because of the carbon paper, the signature will be transferred onto the ballot. 3. When votes are tallied, the counter can unseal the envelope and find the certifier’s signature on the ballot, thereby knowing the vote was valid. This system ensures that the certifier can authenticate a ballot without seeing the actual votes, and the content of the ballot can then be verified by a different party who doesn’t know the voter! ### Blind RSA Signatures In the digital realm, this system can be replicated with cryptographic primitives like RSA blind signatures. In the issuance process, clients can generate a bunch of random nonces $n$, and then *blind* them with a blinding factor $r$. The blinding process works such that any signature on the blinded nonce will also be valid on the unblinded nonce, but the signer cannot recover the unblinded nonce because it doesn’t know what $r$ is. In practice, Privacy Pass has moved toward VOPRF-based constructions, but blind RSA is easier to explain and shares many of the same properties. We’ve skipped over some details of RSA blind signing here, but this should give you a bit of an intuition of how it works. ## Architecture As a thought exercise, what could a UX-friendly private API credits architecture look like? Let’s define the components first: * **Provider**: hosts a **portal** for users to sign up and purchase credits, and hosts protected **resources** that users want to access privately. In the case of RPC providers, the resources would be the nodes they host, accessible over JSON-RPC. * **User**: user of the system, signs up for an account and purchases credits through a portal. Interacts with the resources through a **client**. * **Client**: software operated by the user that interacts with the provider and its **resources**, and abstracts away the complexities of dealing with private credits. The logic of both the **Provider** and the **Client** will have to be modified to replace regular API keys with private credits. However, for ease of use, we can still use a key that uniquely identifies a user, and allows the client to programmatically request private credits from the provider, and the provider to keep track of balances. This is not a problem, because all this information is contained to the initial authentication and issuance context. This key will **not** be used when making actual API requests. We’ll call this key the **Account Key**. Architecture Schematic overview of the architecture. ### Client We’ll start with the client. One of our goals here is to minimize the friction introduced by private credits. Therefore, the client should encapsulate all of the following logic: * Producing random nonces and blinding them * Interacting with the provider to receive blinded, private API credits * Attaching those credits to RPC requests to authenticate them As mentioned before, we can make use of the account key. This is a key that uniquely identifies the user and has an associated balance that it can mint in private API credits. All the user has to do is purchase credits on the portal and instantiate the client library with the account key. It will take care of the rest. ### Provider We can explain the functionality that would need to be added to the provider in the form of a hypothetical Private Credits SDK. This SDK would be initialized with a keypair (the **Issuance Key**) of which the public key is known to all clients. The corresponding private key will be used to sign private credits with. The portal would manage user billing and account keys, and upon issuance it will use the SDK to blind sign credits. One key requirement of the SDK is that it should contain some nullifier set of already spent credits to prevent double-spending. This means that on credit redemption, the unblinded nonces provided with API requests should be added to a cache to indicate that they’ve been spent. ## Practical Considerations ### Nullifier State Edge services need to redeem and validate private credits, which includes invalidation by adding the nonce to a nullifier set. To limit state growth, the first requirement is the use of epochs. These are fixed intervals of time that set bounds on the validity of a private credit (i.e., they expire after 1 epoch). This alleviates the state growth problem by being able to purge the nullifier set after every epoch. **Data Structure** The second consideration is that of which data structure to use, which can also have a big impact on state growth. We outline 2 options here: * Small but probabilistic * Large but deterministic The first option contains data structures like Bloom filters that are extremely space-efficient. They can be used for membership testing just like a hash set, but they can render occasional false positives. In this case, this could mean that a certain credit could be wrongly marked as spent! Fortunately, because epochs limit the amount of possible items in a filter, it’s possible to configure the size of the Bloom filter to make these events sufficiently rare. Alternatively, newer constructions like [cascading Bloom filters](https://philthompson.me/misc/cascading-bloom-filters/) could be used as well. The second option uses sets. These are deterministic (no false positives), but grow linearly with the number of elements in the set, and are thus not very space-efficient. **To Share Or Not To Share?** In a highly-available setup, many API endpoints may exist to service requests. This means that each of them should have access to the latest nullifier set. Sharing this state directly between services with strong consistency is not an option, because it would require consensus between potentially distributed services. What we would recommend instead is to have a dedicated service for redeeming credits that manages the nullifier state. One could make this highly available, but probably only within a region to ensure low latency. ### Compute Units Accounting Since RPC providers use compute units for accounting, private credits should be denominated in CUs. For example, 1 credit could represent 10 CUs. This works for all models where the cost is known upfront. However, more research is needed to make this work for models with dynamic API costs, like LLM APIs with token accounting. ### Latency Modern Rust-based privacy pass libraries like the one used in the Brave Browser are quite fast ([link](https://github.com/brave-intl/challenge-bypass-ristretto)). Signing a batch of 30 tokens takes \~1.2ms, while redeeming 30 tokens takes \~850µs. Check out the benchmark here: [https://github.com/brave-intl/challenge-bypass-ristretto/pull/77](https://github.com/brave-intl/challenge-bypass-ristretto/pull/77). Interacting with the nullifier service is also in the hot-path, so should be accounted for as well. ## Improvements The [Privacy Pass Architecture RFC](https://www.rfc-editor.org/rfc/rfc9576.html) defined 3 logical roles: the Issuer, Attester, and Origin. In the previously discussed design, all of these roles were played by the Provider. The portal authenticates users and verifies balances (attestation), and then issues credits (issuance). The resources here are also exposed by the provider (origin). The RFC actually [mentions](https://www.rfc-editor.org/rfc/rfc9576.html#name-shared-origin-attester-issu) that there *could* be cases where this presents a risk. All of these risks boil down to the simple observation that privacy loves company. If your anonymity set is small, because this feature is exposed as a separate paid plan that initially does not have a lot of users, you have a chicken-and-egg problem on your hands. With only a small set of users, the anonymity guarantees are quite low. But this would be mitigated as soon as there are more users using the feature, and the more users, the stronger the privacy guarantees. On the other hand, if this feature is introduced as a default, the problem would not exist. Another risk the RFC mentions is the following: > *Origin-Client, Issuer-Client, and Attester-Origin unlinkability requires that > issuance and redemption events be separated over time \[…], or that they be > separated over space, such as through the use of an anonymizing service when > connecting to the Origin.* Separation over time is useful because of timing correlation analysis: if a user gets issued credits, and immediately after that starts redeeming them, it could be possible to correlate the 2 events. Note that this is once again tied to the size of the anonymity set: if there are many users, that are constantly issuing and redeeming, this analysis becomes sufficiently hard. But to be safe, the client should implement this separation in time. Separation in space is related to network metadata: if the same IP is used in the issuance process, and later on when redeeming credits, unlinkability is also lost. It is therefore necessary to anonymize either of the 2 interactions through a third-party service. Another IETF standard that could be leveraged here is [Oblivious HTTP (RFC 9458)](https://www.rfc-editor.org/rfc/rfc9458.html). Oblivious HTTP introduces third-party relay in the request path that hides network metadata from the origin, while not being able to deduce anything about the request itself other than its source and destination. From a trust perspective, it’s similar to [iCloud Private Relay](https://blog.cloudflare.com/icloud-private-relay/): it works unless the 2 parties collude. ## Use Cases We’ve explored private API credits from the perspective of private RPC requests. As mentioned before though, the construction is general enough to work in any deployment that uses API keys for authentication. It is especially relevant in scenario’s where decoupling account information (PII), with API usage is desirable. One particularly interesting case is **private LLM provider APIs**. With private credits, prompts and conversations will be unlinkable to PII (unless you include PII in the prompts). We think that this should be an option that LLM users have if they don’t want the providers to be able to construct a complete picture around their identity. There are some interesting open problems here though: * Conversations are by their nature a chain of messages, so achieving unlinkability between requests is not really possible. Maybe this is less of an issue as long as it can’t all be linked back to a certain identity. * The exact cost of an API call in tokens is not possible to know in advance, so a variant of this that works with dynamic costs should be investigated. ## Conclusion All of the primitives for replacing the API key with private credits are there, and on top of that, they are standardized by the IETF. Multiple companies have open-source, audited implementations of the cryptography. In conclusion, all of the building blocks exist, and are waiting to be put together. If you are interested in collaborating with us on this, please reach out to [dev@chainbound.io](mailto:dev@chainbound.io) or ping [@mempirate](https://x.com/mempirate) on X. # Estimating Validator Decentralization Using P2P Data This research was funded by the Robust Incentives Group at the Ethereum Foundation. This work is specifically related to ROP-8. Additional information can be found [here](https://www.notion.so/bad7233658cc41f38b26e7b4f6cf6e8b?pvs=21). We want to thank [soispoke](https://x.com/soispoke), the [EF DevOps team](https://x.com/EthPandaOps), [MigaLabs](https://migalabs.io/) and [ProbeLab](https://probelab.io/) for their advice and contributions! ## Introduction The geographical distribution of a validator set is [one of the most critical factors](https://collective.flashbots.net/t/decentralized-crypto-needs-you-to-be-a-geographical-decentralization-maxi/1385) in determining a blockchain's level of decentralization. Validator decentralization is vital for Ethereum. It enhances network security, resilience, and censorship resistance by distributing control and minimizing the risk of single points of failure or malicious attacks. It is well known that Ethereum has a [very large](https://beaconcha.in/charts/validators) validator set, but **is this validator set geographically distributed?** Ethereum has a substantial amount of beacon nodes running on the consensus layer network, with current estimates at around \~12,000 active nodes ([source](https://nodewatch.io/)). A beacon node serves as a *potential* entrypoint into the network for validators, but it is not representative of the actual validator distribution. Probably not. ## Anatomy of a validator An Ethereum validator is a virtual entity that consists of a balance, public key and other properties on the beacon chain. They are roughly responsible for 4 things: 1. Proposing new blocks 2. Voting on other block proposals (attesting) 3. Aggregating attestations 4. Slashing other validators in case they commit faults A *validator client* is the piece of software that executes these responsibilities for each of its registered validator keys (which can be many). But a validator client on its own cannot connect to the P2P beacon network to talk directly to other validators. Instead, it connects to an entity known as a *beacon node*, which is a standalone client that maintains the beacon chain and communicates with other beacon nodes. Schematic of validator clients and a beacon node. Beacon nodes can have a number of validators attached to them that ranges from zero to thousands. In fact, [it’s been reported](https://medium.com/@grandine/grandine-0-4-1-released-fb98daef6d60) that in some Ethereum testnets client developers have been running upwards of 50k validators on a single machine. This separation of concerns makes our investigation somewhat harder: a simple crawl of the P2P network might give us a good overview of the set of online beacon nodes in real time, but this is not representative of the overall validator client distribution at all. Before we address this problem, we’ll take a closer look at validator duties and their footprint on the network. ## Attestation duties and committees As mentioned above, one of the main responsibilities of a validator is voting on blocks by broadcasting *attestations*. These attestations express the view of a validator about which chain they think is correct. In more detail, they actually cast 2 different votes: one to express their view of the current head block, and one to help finalize past blocks. This is because Ethereum’s consensus is a combination of [2 subprotocols](https://arxiv.org/pdf/2003.03052): LMD Ghost, a fork-choice rule, and a finality gadget called Casper FFG. These duties are assigned randomly every epoch (with some [lookahead](https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/validator.md#lookahead)) with RANDAO as the source of randomness. Validators get assigned to one slot per epoch at which they have to cast their attestation, which is just a message with the votes that is signed over with the validator BLS private key. These votes are then to be packed and stored in the next beacon block. However, if [all 1 million validators](https://beaconcha.in/charts/validators) were to attest for every block, the network would be flooded with messages, and the proposer that is supposed to pack these attestations into their block would have trouble verifying all of those signatures in time. This would make Ethereum’s design goal of low resource validation unfeasible. To address these issues, the beacon network is subdivided into *committees*, which are subsets of the active validator set that distribute the overall workload. Committees have a minimum size of $128$ validators, and there are $64$ committees that are assigned per slot. But how is this achieved in practice? What network primitives do we require to enable such a logical separation? ## Attestation subnets The Ethereum consensus P2P network is built with [GossipSub](https://github.com/libp2p/specs/tree/master/pubsub/gossipsub), a scalable pubsub protocol running on libp2p. Being a pubsub protocol, GossipSub supports publish/subscribe patterns and the segmentation of networks into logical components called *topics* (aka P2P overlays)*.* These are the networking primitives that underpin beacon committees. One example of a topic is the [`beacon_block`](https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/p2p-interface.md#beacon_block) topic, which is a *global topic* on which new beacon blocks are broadcast. Every validator must subscribe to this topic in order to update their local view of the chain and perform their duties. The attestation overlays look quite a bit different. For each committee, we derive a subnet ID based on the committee index (0-64). The topic for the respective subnets then becomes [`beacon_attestation_{subnet_id}`](https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/p2p-interface.md#beacon_attestation_subnet_id). Every validator knows their upcoming attestation duties at least 1 epoch ahead of time and can join the correct subnet in advance. When they have to make an attestation, they broadcast it on this subnet. As mentioned before, these attestations are eventually supposed to make it into a beacon block. But since upcoming proposers might not be subscribed to these subnets, how does that work? This is where *attestation aggregators* come in. These are a subset of the beacon committees that are responsible for *aggregating* all of the attestations they see and broadcasting the aggregate attestations on the global [`beacon_aggregate_and_proof`](https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/p2p-interface.md#beacon_attestation_subnet_id) topic. This topic is again a mandatory global topic that all validators will be subscribed to, thus providing a way for local unaggregated attestations to make it into the global view of the network. Per committee, there’s a target number of aggregators of $16$. ### Subnet types These attestation subnets described above are ephemeral and directly tied to the validator duties. We call these **short-lived** attestation subnets. The problem with these ephemeral subnets is that they are not very robust, and could result in lost messages. To deal with this issue, the notion of a “[subnet backbone](https://github.com/ethereum/consensus-specs/issues/2749)” was introduced. This backbone consists of **long-lived**, persistent subnet subscriptions that are not tied to validator duties but rather a [deterministic function](https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/p2p-interface.md#attestation-subnet-subscription) of the beacon node’s unique ID and the current epoch. These long-lived subnets are maintained for $256$ epochs, or around 27 hours, and each beacon node has to subscribe to 2 of them. They are also advertised on the discovery layer, making it easier for beacon nodes with certain duties to find peers on the relevant subnets. ## Validator footprints Returning to the separation of the beacon node and validator clients, there’s now a clear footprint that validators leave on the beacon node’s network identity: their short-lived subnet subscriptions. This will be the core of our methodology. ## Methodology Generally, the beacon network consists of 3 domains: * The discovery domain * The Req/Resp domain * The gossip domain Each of these domains provides some information about a beacon node. ### Long-lived subnets & node metadata At the **discovery layer** ([discv5](https://github.com/ethereum/devp2p/blob/master/discv5/discv5.md)), a beacon node’s identity consists of an [ENR](https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/p2p-interface.md#enr-structure) with some additional metadata. This metadata can roughly be represented as the following object: ```js { (peer_id, ip, tcp_port, udp_port, attnets, fork_digest, next_fork_version, next_fork_epoch); } ``` This metadata helps other peers connect to peers that are relevant to them, indeed, one of the extra metadata fields are the (long-lived) attestation subnets that this node is subscribed to! The **Req/Resp domain** is where the actual handshake happens. This is where nodes exchange `Status` messages that look like the following in order to establish a connection: ```js ( fork_digest: ForkDigest finalized_root: Root finalized_epoch: Epoch head_root: Root head_slot: Slot ) ``` The underlying protocol used for the Req/Resp domain is (again) libp2p. On the lower levels, additional information like `client_version` is also exchanged when connections are set up. It is at this level that peers can also exchange `MetaData` objects to identify each other’s most up to date long-lived subnet subscriptions. The [`MetaData`](https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/p2p-interface.md#metadata) object looks like this: ```js ( seq_number: uint64 attnets: Bitvector[ATTESTATION_SUBNET_COUNT] ... ) ``` ### Short-lived subnets So far, we’ve only seen how nodes exchange metadata and their long-lived subnet subscriptions, which tell us nothing about potential validators. For that, we need the short-lived subnets, which we can only collect on the gossip domain. Our initial strategy was doing just that: 1. Listen to incoming topic subscription requests 2. Save and index them However, on an initial review of the data, we saw way too many beacon nodes that didn’t subscribe to any additional subnets besides their long-lived, mandatory subscriptions. Our assumption was that in order to publish data on a gossipsub topic, one needed to be subscribed to it. It turns out that this is not the case, and many clients have different behaviour to minimize bandwidth and CPU usage. Rather than subscribing to the subnet directly, the peer finds other peers that are subscribed to the required subnet beforehand and shares the attestation with them. The subscribed peers make sure to verify and forward these attestations. Remember that in theory, only attestation aggregators need to be listening to all incoming attestations in order to do their jobs. This is exactly what was happening, and explains why we had so little short-lived subnet observations. With this understanding, we could now tune our assumptions: * For each subnet, there’s a target of `TARGET_AGGREGATORS_PER_COMMITTEE=16` aggregators per committee * This means that on average, there will only be $16$ validators per committee that will be subscribed to an additional short-lived subnet for the duration of an epoch * This results in a maximum of $16 * 32 * 64 = 32768$ useful observations per epoch With these assumptions in mind, we can start estimating validator counts. ### Estimating validator counts For each observation, we subtract the number of long-lived subnets $S_l$ from all subscribed subnets $S_{all}$ to arrive at the number of short-lived subnets $S_s$: ```math S_s = S_{all} - S_l ``` Since we know aggregators are subscribed to one additional subnet per epoch, $S_s$ will result in an estimated validator count for a certain beacon node in this epoch. Note that just one observation will not be enough to get an accurate estimate, because of the following reasons: * It could be that a validator is not an aggregator for this epoch, and thus won’t subscribe to any subnets * There could be overlap between the long-lived and short-lived subnets Due to this reason, we continuously try to collect observations for each known beacon node per epoch, and save the maximum estimated validator counts. Note also that the ceiling for validator estimations is at $64 - 2$, because that’s the maximum amount of short-lived subnets we can record. This is important! It means that for beacon nodes with more than $62$ validators, we can not estimate how many there are, and just record the ceiling. We want to highlight again that this is just an estimation and won’t be a very accurate representation of the total number of validators. ## Architecture In this section we’ll dive a bit deeper into the architecture. All the code for this is open source and can be found in this repository: [https://github.com/chainbound/valtrack](https://github.com/chainbound/valtrack). A lot of the crawler code is based on projects like [Hermes](https://github.com/probe-lab/hermes) and [Armiarma](https://github.com/migalabs/armiarma/). An overview can be seen here: ### Crawler The crawler is the core component of the system. It will crawl the discv5 discovery DHT, find nodes that are on the correct network by looking at the metadata in their ENRs, and then try to connect with them. It will keep a local cache of known peers and try to reconnect every epoch to get updated observations. We outline 2 types of events (observations): `PeerDiscoveryEvent` and `MetadataReceivedEvent`. The second one is most relevant and contains the following fields: ```go type MetadataReceivedEvent struct { ENR string `json:"enr"` ID string `json:"id"` Multiaddr string `json:"multiaddr"` Epoch int `json:"epoch"` MetaData *eth.MetaDataV1 `json:"metadata"` SubscribedSubnets []int64 `json:"subscribed_subnets"` ClientVersion string `json:"client_version"` CrawlerID string `json:"crawler_id"` CrawlerLoc string `json:"crawler_location"` Timestamp int64 `json:"timestamp"` // Timestamp in UNIX milliseconds } ``` Along with some metadata, this contains all of the fields required to apply the previously described methodology: `SubscribedSubnets` contains the actually subscribed subnets, obtained by listening on the GossipSub domain, and `MetaData` contains the peer’s long-lived subnets. All of these events are then sent to a persistent message queue, where they are stored until they’re read by the consumer. ### Consumer The consumer turns the event logs into a stateful view of the network by implementing the methodology described above. It parses the short-lived subnets from the metadata events to get the estimated validator counts, and updates any existing entries in its stateful view. This stateful view is saved in a local sqlite database, which we expose over an API. The table schema roughly looks like this: ```sql validator_tracker ( peer_id TEXT PRIMARY KEY, enr TEXT, multiaddr TEXT, ip TEXT, port INTEGER, last_seen INTEGER, last_epoch INTEGER, client_version TEXT, possible_validator BOOLEAN, max_validator_count INTEGER, num_observations INTEGER, hostname TEXT, city TEXT, region TEXT, country TEXT, latitude REAL, longitude REAL, postal_code TEXT, asn TEXT, asn_organization TEXT, asn_type TEXT ) ``` We then join this data together with an IP location dataset to provide more information about geographical distribution. ## Results [Chainbound](https://www.chainbound.io/) runs a [https://github.com/chainbound/valtrack](https://github.com/chainbound/valtrack) deployment that pushes all data to Dune every 24 hours. *This data has been stripped of sensitive information such as IP addresses and exact coordinates. However, it retains information like city, coordinates with a precision of a 10km radius, and ASN information.* **An example dashboard leveraging this information can be seen [here](https://chainbound.grafana.net/d/fdry7answ53b4a/ulisse-v2?orgId=1\&from=now-6h\&to=now\&timezone=browser)**. ## Limitations * The maximum number of validators we can estimate with this methodology per beacon node is 62, due to that being the maximum amount of short-lived subnet subscriptions. This will result in a significantly underreported total number of validators, but should still be able to provide a reasonable estimation of the geographical distribution. * We failed to gather any meaningful data on Teku nodes over the 30-day period, which could signify an error in our P2P implementation and impact the results. * These results will be skewed towards validators attached to beacon nodes that have opened P2P networking ports in their firewall, which will mostly be beacon nodes running on cloud providers. The reason for this is that our crawler can more easily connect to nodes that have exposed ports. ## References * [https://eth2book.info/capella/](https://eth2book.info/capella/) * [https://hackmd.io/@dmarz/ethereum\_overlays](https://hackmd.io/@dmarz/ethereum_overlays) * [https://github.com/ethereum/consensus-specs/tree/dev](https://github.com/ethereum/consensus-specs/tree/dev) # Exploring Verifiable Continuous Sequencing with Delay Functions *Thanks to Conor, Lin and Swapnil from the Switchboard team, Cecilia and Brecht from the Taiko team, Alex Obadia, Justin Drake, Artem Kotelskiy and the Chainbound team for review.* ## Abstract Agreeing on time in a decentralized setting can be challenging: wall clocks may drift between machines, agents can lie about their local times, and it is generally hard to distinguish between malicious intent and just unsynchronized clocks or network latencies. Ethereum can be thought of as a global clock that ticks at a rate of 1 tick per \~12 seconds. This tick rate is soft-enforced by the consensus protocol: blocks and attestations produced too early or too late will not be considered valid. But what should we do in order to achieve a granularity lower than 12 seconds? Do we always require a consensus protocol to keep track of time? We want to explore these questions in the context of untrusted L2 sequencers, who don't have any incentive to follow the L2 block schedule that is currently maintained by trusted L2 sequencers, and will likely play various forms of timing games in order to maximize their revenue. In this article, we introduce mechanisms to enforce the timeliness, safety and non-extractive ordering of sequencers in a decentralized rollup featuring a **rotating leader mechanism**, without relying on additional consensus, honest majority assumptions or altruism. To do so, we use three key primitives: 1. Client-side ordering preferences, 2. Ethereum as a global 12s-tick clock, 3. Verifiable Delay Functions. Lastly, we show the case study of MR-MEV-Boost, a modification of MEV-Boost that enables a variation of based preconfirmations, where the same construction explored can be applied to reduce the timing games of the proposer. ## Rationale Rollup sequencers are entities responsible for ordering (and in most cases, executing) L2 transactions and occasionally updating the L2 state root on the L1. Currently, centralized sequencers benefit from the reputational collateral of the teams building them to maintain five properties: * **Responsiveness**: responding to user transactions with soft commitments / preconfirmations in a *timely* manner. We want to highlight that this definition includes the timely broadcast of unsafe heads on the rollup peer-to-peer network. * **Non-equivocation (safety)**: adhering to preconfirmation promises when submitting the ordered batch on the L1, which is what will ultimately determine the total ordering of transactions. * **Non-extractive ordering**: not extracting MEV from users by front-running or sandwiching, or by accepting bribes for front-running privileges. * **Liveness**: posting batches to L1 and updating the canonical rollup state regularly. * **Censorship-resistance:** ensuring that no valid transactions are deliberately excluded by the sequencer regardless of the sender, content, or any external factors. In this piece we are concerned with how the first four properties can be maintained in a permissionless, untrusted setting. Note that censorship-resistance is ensured by construction: by introducing multiple organizationally distinct sequencers in different geographies and jurisdictions we have a strong guarantee that any transaction will be accepted eventually. Consider a decentralized sequencer set $S := \{S_1,\dots,S_n\}$ with a predictable leader rotation mechanism and a sequencing window corresponding to a known amount of L1 slots. For simplicity, let’s assume $S_{i}$ is the current leader and $S_{i+1}$ is the next one. At any point in time, only one sequencer is active and has a lock over the rollup state. Here are two strategies that sequencer $S_i$ can explore to maximize its expected value: **1. Delaying the inclusion of transactions** Suppose a user sends a transaction to $S_i$ at a certain L2 slot. Then, the sequencer could wait some time before inserting the transaction into a block in order to extract more MEV with sandwich attacks in collaboration with searchers or by directly front-running the user. In particular, [since MEV grows superlinearly with time](https://www.youtube.com/watch?v=01dnINiLhAk\&t=287s), it’s not in the sequencer’s best interest to commit early to a transaction. The worst case scenario would be the sequencer delaying inclusion until the sequencer rotation$^1$. **2. Not publishing unsafe heads in the rollup peer-to-peer network** In this setting the sequencer has low incentives to publish the unsafe heads in the rollup network: since L2 blocks are signed by the sequencer (e.g. in [Optimism](https://docs.optimism.io/builders/node-operators/configuration/consensus-config#p2psequencerkey)), they act as a binding commitment which can be used by users to slash it in case of equivocations. This has a major downstream consequence on the UX of the rollup: both the next sequencer and users need to wait until a batch is included to see the latest transactions. For users it means they won't know the status of their transactions in a timely manner, while the next sequencers risks building blocks on invalid state. We will now explore mechanisms to mitigate these behaviours and introduce slashing conditions for sequencers. ## Primitive 1: Transaction Deadlines We introduce a new EIP-2718 transaction type with an additional field: * `deadline` - `uint256` indicating the last L2 block number for which the transaction is considered valid. This idea is not entirely new. For instance, the [LimeChain](https://limechain.tech/) team has explored this in their [Vanilla Based Sequencing](https://github.com/LimeChain/based-preconfirmations-research/blob/main/docs/preconfirmations-for-vanilla-based-rollups.md#preconfirmation-deadline) article. However, in our variant the `deadline` field is signed as part of the transaction payload and it is not expressed in L1 slots. The reasoning behind it is that the sequencer cannot tamper with either the `deadline` field or `block.number` (because it is a monotonically increasing counter), and therefore it is easy to modify the L2 derivation pipeline to attribute a fault in case the sequencer inserts the user transaction in a block where `block.number > deadline`. This approach mitigates problem #1. However, it does not in any way solve the *responsiveness* issue, since sequencers can still delay proposing the block in order to extract more MEV. ## Primitive 2: Ethereum as a Global Clock A simple rotating sequencer design would be one where $S_i$ loses the power to settle batches after the end of its sequencing window $W_i$, which is dictated by an L1 smart contract. However, the sequencer still needs some time to post the batch with the latest L2 blocks. We therefore introduce an *inclusion window* that is shifted $n \geq 1$ slots ahead of $W_i$, where $S_i$ still has time to land rollup batches on L1 with the last L2 blocks, even if the responsibility of sequencing has shifted to $S_{i+1}$. In case of any safety fault, the sequencer should be slashed. If the sequencer has not managed to post all their assigned L2 blocks by the end of its inclusion window, it will forego all associated rewards. Optionally, there could also be penalties for liveness faults. This also helps with the problem of collaboration with the next sequencer, by ensuring that the latest blocks will be known to it within $n\cdot12$ seconds. Ideally, we’d like to keep $n$ as small as possible with a value of $1$. There are still some potential issues here: getting a transaction included on Ethereum is probabilistic, meaning that you can’t be sure that a transaction you send will actually be included in time. In this context it means that the last batch sent by an honest leader may not be included in the L1 by the end of its inclusion window. This can be helped with two approaches: * A “based” setup, where the sequencer is also the L1 block proposer and can include any transactions right up to the point they have to propose, or * Using proposer commitments with a protocol like [Bolt](https://boltprotocol.xyz). We expand more on this in the *”Further work”* section below. Note that we assume there is a registry smart contract that can be consulted for the currently active sequencer, i.e. it implements some leader election mechanism and takes care of sequencer bonds along with rewards and penalties. It is up to the rollup governance to decide whether the registry can be fully permissionless or if it should use an allowlist. In case of any misbehaviour, governance would be used to temporarily or permanently remove the sequencer from the allowlist. ## Primitive 3: Verifiable Delay Functions [Verifiable Delay Functions](https://medium.com/iovlabs-innovation-stories/verifiable-delay-functions-8eb6390c5f4) (VDFs henceforth) are a cryptographic primitive that allows a prover to show a verifier that a certain amount of time was spent running a function, and do it in a way that the verifier can check the result quickly. For instance, consider a cryptographic hash function $h$ and define the application ```math H(n,s) := (h \circ \underset{n\ times}\dots \circ h)(s), ``` where $s$ is a byte array and $n$ is a natural number. Composing (or chaining) hash functions like SHA-256 cannot be trivially sped up using parallel computations, but the solution lacks efficient verification$^2$, as the only way to verify the result is to recompute the composition of functions. This solution appeared as a naïve VDF in [Boneh's paper](https://eprint.iacr.org/2018/601.pdf), and for this reason it is referred to as *weak*. Another example of VDF is [iterated squaring over a group of hidden order](https://people.csail.mit.edu/rivest/pubs/RSW96.pdf), with which it is possible to construct time-lock puzzles. We’ll explore the usage of the latter in the next sections. ### Why VDFs tho? VDFs are very useful in the context of sequencing because they can act as a *proof of elapsed time* for the duration of the block (specifically `block_time` / `max_adversary_speedup`, see *"Security Considerations"*). Consider the following algorithm for the block production pipeline: 1. At the beginning of L2 block $N$, the sequencer starts computing a VDF that takes an L2 block time (or slightly less) to compute for honest players, using the previous block hash as its input. 2. After the end of the L2 slot the sequencer builds a block $B_N$ where the header contains the result of the VDF, denoted $V_N$. We call this *sealing* a block. This means the block hash digest contains $V_N$. VDF Scheme This algorithm has the nice property of creating a chain of VDF computations, in some sense analogous to [Solana’s Proof of History](https://solana.com/news/proof-of-history) from which we inherit the security guarantees. What does this give us in the sequencer context? If we remember that a sequencer has a certain deadline by which it has to post batches set by the L1 slot schedule, we can have the L1 enforce that *at least* some number of L2 blocks need to be settled. This has two downstream results: * The sequencer *must* start producing and sealing blocks as soon as their sequencing window starts. Pairing this with the transaction deadline property results in an upper bound of time for when a transaction can be confirmed. If they don’t follow the block schedule set by the VDF and the L1, they risk not being able to post *any* batch. * We mitigate problem #2 by taking away the incentive to withhold data (not considering pure griefing attacks): this is because the sequencer cannot tamper with an existing VDF chain, which would require recomputing all the subsequent VDFs and result in an invalid batch. In general, for the sake of this post we will consider a generic VDF, provided as a “black box” while keeping the hash chain example in mind which currently has stronger guarantees against ad-hoc hardware such ASICs. See *“Security Considerations”* below for more insights. ### Proving correct VDFs If a sequencer provides an invalid VDF in an L2 block header it should be slashed, and ideally we’d like to ensure this at settlement time. However, recalculating a long hash chain on the EVM is simply unfeasible due to gas costs. How to show then that the number of iterations of the VDF is invalid? One way could be to enforce it optimistically (or at settlement, in case of ZK-rollups) by requiring a valid VDF chain output in the derivation pipeline of the rollup. In case of equivocation in an optimistic rollup the sequencer can be challenged using fraud proofs. ### Hardware requirements Since by definition VDFs cannot be sped up using parallelism, it follows that computing a VDF can be done by only using a single core of a CPU, and so it does in our block production algorithm. This makes it different and way more lightweight compared to most Proof-of-Work consensus algorithms such as Bitcoin’s which requires scanning for a value such that, when hashed with SHA-256, the hash begins with a certain number of zero bits. It’s also worth to note that modern CPUs are optimized to compute the SHA-256 hash function. Since 2016 Intel, starting with the *Goldmount* family of chips, is offering [SHA Extensions](https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sha-extensions.html) in the *Core* and *Xeon* line-ups on selected models which introduces three new instructions specialized in computing different steps of the hash function algorithm more efficiently. Lastly, [single-core performance has stagnated over the years](https://www.man.com/single-core-stagnation-and-the-cloud) indicating that there is a minor benefit in investing in the latest generation of CPUs, thus lowering down the requirements of the system. ## Case Study: MR-MEV-Boost [Multi-Round-MEV-Boost](https://ethresear.ch/t/based-preconfirmations-with-multi-round-mev-boost/20091), is a modification of MEV-Boost that enables based preconfirmations by running multiple rounds of MEV-Boost auctions within a single L1 slot. The usage of this primitive is to output after each round a based rollup block built by L2 block builders. As shown in the article, this approach inherits the L1 PBS pipeline and mitigates some of the negative externalities of based preconfirmations as a result. Like MEV-Boost, this fork relies on the opted-in proposer to be an auctioneer which ends the sealed auction by calling the [`getHeader`](https://ethereum.github.io/builder-specs/#/Builder/getHeader) endpoint of the relays. After having signed the sealed bid, the [`getPayload`](https://ethereum.github.io/builder-specs/#/Builder/submitBlindedBlock) is called by the proposer to receive the actual content of the winning bid and to publish the block in the based rollup network. In the original protocol, the end of the auction usually coincides with the end of the L1 slot (more precisely, [near one second after it](https://mevboost.pics/)); delaying it results in a high risk of not being able to broadcast the block in time to gather all the needed attestation and forgo all its associated rewards. As such, a block time is proposed every twelve seconds with consistency, enforced by Ethereum consensus. In contrast, given it consists of multiple rounds happening *during* the slot, in MR-MEV-Boost an *untrusted proposer is incentivized to end the auction seconds later or earlier*$^{3}$ *according to the incoming bids,* in order to extract more more MEV. In the worst case, MR-MEV-Boost will reflect L1 block times. Another consequence of this is an inconsistent slot time for the based rollup. This can be seen as a much more serious form of timing games. In the article, the discussed possible solutions to this problem are the following: 1. Introduce user incentives: if users determine that a proposer is misbehaving, they stop sending transactions to said proposer. 2. Introduce a committee (consensus) to attest to timeliness and maintain slot durations. We now argue that a trustless solution that strongly limits the proposer without requiring actions from the user does exist, and it leverages the same construction we used for the VDF-powered block production algorithm in the context of decentralized sequencing. The construction is fairly simple and consists of computing a VDF that lasts $x := 12/r$ seconds, where $r$ is the number of rounds in an L1 slot (the L2 block time). The proposer must calculate this VDF using the previous based rollup block hash as public input and, at the end of the round, sending it along with the body of a modified `getPayload` call. The output of the VDF is then stored in the rollup block header and if invalid can result in slashing the proposer after a successful fraud proof. With this approach the amount of time a proposer can delay the end of a round is limited: for instance if the first auction ended one second later then during the last round it won’t be able to provide three seconds of computation for the VDF but two, resulting in an invalid block and consequent slashing$^4$. This is because in order to start computing a valid VDF, it requires the previous block hash as its input, implying a sealed block. ## Security Considerations **Are VDFs really safe for this purpose?** Suppose an adversary owns hardware which is capable of computing the VDF faster compared to the baseline of honest players *without getting noticed* (otherwise the number of iterations for the VDF is adjusted by the protocol). Then, the faster the attacker (`max_adversary_speedup`), the less our construction would constrain the space of its possible actions. In particular, the sequencer would be able to commit a bit later to blocks and be able to re-organize some of them for extracting more value. However, given we don’t need the “fast proving” property, hash-chains have proven to be robust with Solana’s Proof of History and will continue to be at least in the short-term. Also, our security requirements will not be as strict as something that [needs to be enshrined in Ethereum](https://ethresear.ch/t/statement-regarding-the-public-report-on-the-analysis-of-minroot/16670) forever. Some solutions and directions to get stronger safety guarantees can be found in the *”Further work”* section below. ## Current limitations **Sequencer credibility** As with many new services which leverage (re)staking, the credibility of the sequencer has an upper bound which is the amount it has staked: if a MEV opportunity exceeds that, then a rational untrusted actor would prefer to get slashed and take the MEV reward. **Leader rotation can be a critical moment** As discussed in the batcher and registry smart contract section, the inclusion window is shifted of one slot forward at minimum compared to the sequencing window. This is needed because of the time required to settle the last batch before rotating leader, but leaves an additional slot time of at least 12 seconds in which the sequencer has room to re-organize the last L2 blocks before publishing them on the rollup peer-to-peer network. As a consequence, liveness is harmed temporarily because $S_{i+1}$ might be building blocks on invalid state if it starts to sequence immediately. Lastly, one additional slot might not be enough to settle a batch according to recent data on [slot inclusion rates for blobs](https://ethresear.ch/t/slot-inclusion-rates-and-blob-market-combinatorics/19817). This can be mitigated by leveraging new inclusion preconfirmation protocols, as explained below. **Sequencer last-look** Our construction makes very difficult for a sequencer to reorg a block after it has been committed to, however it doesn’t solve front-running in its entirety. In particular, the sequencer may extract value from users transactions while building the block with associated `deadline` field. A possible solution along with its limitations is explored in the section below. ## Conclusion In this article, we explored mechanisms to enforce the timeliness, safety, and non-extractive ordering of untrusted L2 sequencers in a decentralized rollup environment. The primitives discussed ensure that sequencers can act more predictably and fairly, mitigating issues such as transaction delays and data withholding. Moreover, these techniques can reduce trust assumptions for existing single-sequencer rollups, aligning with the concept of rollups functioning as [“servers with blockchain scaffolding”](https://vitalik.eth.limo/general/2024/06/30/epochslot.html#what-should-l2s-do). These findings provide a robust framework for the future development of decentralized, secure rollup architectures. ## Further work **Trusted Execution Environments (TEEs) to ensure the sequencer is not running an ASIC** A [Trusted Execution Environment](https://en.wikipedia.org/wiki/Trusted_execution_environment) is a secure area of a CPU, often called *enclave*, that helps the code and data loaded inside it be protected with respect to confidentiality and integrity. Its usage in blockchain protocols is an active area of research, with the main concerns being trusting the hardware manufacturer and the [various vulnerabilities found in the past](https://en.wikipedia.org/wiki/Software_Guard_Extensions) of some implementations (here’s the [latest](https://x.com/_markel___/status/1828112469010596347)). Depending on the use case these trust assumptions and vulnerabilities might be a deal-breaker. However, in our setting we just need a guarantee that the sequencer is not using specialized hardware for computing the VDF, without caring about possible leakage of confidential data from the enclave or manipulation of the wall clock / monotonic clock. **Adapt existing anti-ASICs Proof-of-Work algorithms** The [Monero](https://www.getmonero.org/resources/about/) blockchain, launched in 2014 as a privacy and untraceable-focused alternative to Bitcoin, uses an ASIC-resistant Proof-of-Work algorithm called [RandomX](https://github.com/tevador/RandomX). Quoting their `README`: > RandomX is a proof-of-work (PoW) algorithm that is optimized for > general-purpose CPUs. RandomX uses random code execution (hence the name) > together with several memory-hard techniques to minimize the efficiency > advantage of specialized hardware. The algorithm however leverages [some degree of parallelism](https://github.com/tevador/RandomX/blob/102f8acf90a7649ada410de5499a7ec62e49e1da/README.md#cpu-performance); it is an interesting research direction whether it can adapted into a single-core version, leading to a new weak-VDF. This approach, while orthogonal to using a TEE, can potentially achieve the same result which is having a guarantee that the sequencer is not using sophisticated hardware. **Time-lock puzzles to prevent front-running** As mentioned in the *“Current limitations”* section, our construction doesn’t limit the problem of sequencer front-running the users. Luckily, this can be solved by requiring users to encrypt sensitive transactions using [time-lock puzzles](https://people.csail.mit.edu/rivest/pubs/RSW96.pdf), as we will show in more detail in a separate piece. However, this solution doesn’t come free: encrypted transactions or encrypted mempools can incentive spamming and statistical arbitrage, [especially when the protocol fees are not very high](https://collective.flashbots.net/t/it-s-time-to-talk-about-l2-mev/3593). **Inclusion Preconfirmations and Data Availability layers** Batch submissions to an L1 contract could be made more efficient by leveraging some of the new preconfirmations protocol like [Bolt](https://boltprotocol.xyz) by Chainbound or [MEV-Commit](https://docs.primev.xyz/concepts/what-is-mev-commit) by Primev to have guaranteed inclusion in the same slot. In particular, sequencing windows should end precisely in the slot before one where the proposer is running the aforementioned protocols in order to leverage inclusion commitments. Additionally, the batch could be posted into an efficient and lightweight Data Availability layer run by proposers to enforce a deadline of a configurable amount of seconds in the beginning of the slot, otherwise the sequencer would be slashed. Meme ## Footnotes 1. More precisely, if an operator controls multiple subsequent sequencers it could delay inclusion until the last sequencer rotation. 2. In Solana, the verification of a SHA-256 chain is actually parallelised but requires dividing a block associated to a \~400ms computation into 32 shreds which are forwarded to the rest of the validators as soon as they’re computed. As such, verification is sped up by computing the intermediate steps of the hash chain in parallel. 3. In general, the proposer will end some rounds earlier as a side effect of delaying other rounds. For example, it could force a longer last round to leverage possible L1 \<> L2 arbitrage opportunities. 4. There is an edge case where the proposer might not be able to compute all the VDFs even if honest, and it is due to the rotation mechanism: since the public input of the VDF must be the previous rollup block hash, during rotation the next leader will need some time before hearing the block from the rollup network, potentially more than 1s. This could lead the next proposer to be late in computing the VDFs. To reduce this risk, the next proposer could rely on various parties to receive this information such as streaming services and/or trusted relays. # Vixy: Vibe-coding an Ethereum Node Proxy At Chainbound we regularly run internal experiments. Since Claude launched Opus 4.5, it has been especially fun to push it beyond toy examples. In this experiment, we tried to one-shot an Ethereum Execution Layer and Consensus Layer JSON-RPC proxy. We applied modern engineering practices as guardrails and wanted to see whether Claude can ship a production-ready service quickly and without too much hand-holding. ## Why build a proxy service from scratch? We operate many execution and consensus clients as part of our infrastructure. When nodes fall out of sync or stop responding, we need to detect that automatically and fail over to a backup. This is different from load balancing: we always want a single active node, and we want to switch only when something is wrong. We initially considered existing tools like [blutgang](https://github.com/rainshowerLabs/blutgang) and [sōzu](https://github.com/sozu-proxy/sozu). After some planning, it became clear that they solved a much broader problem than we needed. We would have depended on two external systems and used a small fraction of their features. So we asked a different question: *what if we built a single binary that did exactly what we needed, for both EL and CL, and nothing more?* ## Phase 1: Specification We started by writing a precise spec. The goal was to remove ambiguity before involving an LLM. The service needed to: 1. Read a `config.toml` defining primary and backup nodes. 2. Continuously evaluate node health. 3. Expose HTTP and WebSocket proxy endpoints that always route to a healthy node. Health rules were explicit: * **Execution Layer** Call `eth_getBlockNumber`, compare block heights across nodes, track the highest one as the chain head, and compute lag. A node is unhealthy if it lags more than a configurable number of blocks. * **Consensus Layer** `/eth/v1/node/health` must return HTTP 200, and `/eth/v1/beacon/headers/head` must expose a slot at `/data/header/message/slot`. Track the highest slot and mark nodes unhealthy if their lag exceeds a configurable threshold. ## Phase 2: Prompting the agent The initial prompt lives in [this PR](https://github.com/chainbound/vixy/pull/1). Instead of asking Claude to immediately write code, we asked it to behave like an engineer and produce a plan first. We created an `AGENT.md` file that instructed the agent to think through the system before touching implementation. One trick that worked particularly well was asking Claude to draw an architecture diagram up front. This mirrors how we usually write internal design docs: start with the big picture, then fill in details. The diagram gave us a quick way to validate the overall approach and fix mistakes early, before the agent committed to a direction. Once the architecture made sense, we asked Claude to explain the system end to end and break the work into phases. It produced a clear plan covering project setup, configuration, health checks, proxying, metrics, and testing, with explicit acceptance criteria for each step. From that point on, the agent could work largely autonomously without drifting. **This is where experience matters**. LLMs are good at writing code, but they need constraints. Modern engineering practices act as guardrails. Without them, things get messy quickly. ### Agent-oriented Test Driven Development The core instruction was straightforward: write the tests first, then make them pass. Tests were treated as the source of truth. When Claude claimed something worked, the only thing that mattered was whether the tests passed. When code was refactored, correctness was defined entirely by green tests. By the end, Claude had written 85 unit tests. Every feature started red, then went green, then got cleaned up. This caught real bugs: in one case, unreachable nodes were incorrectly marked healthy due to an edge case in the lag calculation. The failing test exposed it immediately, the logic was fixed, and we moved on. We also used BDD (behavior-driven development) with [Cucumber](https://github.com/cucumber-rs/cucumber). The scenarios read like plain English specs: “Given an EL node at block 1000, when the health check runs, then it should be marked healthy.” There was no ambiguity, so Claude consistently implemented exactly what the scenario described. We ended up with 33 scenarios that function as both executable tests and up-to-date documentation. Unit tests alone are not enough, though. Eventually you have to hit real infrastructure. For that, we used [Kurtosis](https://github.com/ethpandaops/ethereum-package) to spin up a local Ethereum network with four EL nodes and four CL nodes. We ran 16 integration tests against it. This surfaced issues unit tests could not catch, such as missing `Content-Type` header forwarding, which caused Geth to return HTTP 415. Another annoying bug with a trivial fix, caught long before production. ### Context rot and the diary pattern LLMs do not have durable memory. As context grows, earlier details get compressed or lost. To deal with this, we asked Claude to maintain a `DIARY.md` file throughout development. Each entry followed a simple structure: * What was done * Challenges encountered * How they were solved * Key takeaways * Mood The diary turned out to be surprisingly effective. When the Content-Type bug appeared during integration testing, the diary captured the failure, the debugging process, and the fix. When we implemented WebSocket reconnection logic, it documented the design decisions, tests, and type system issues involved. When Claude's context inevitably got compacted after hours of work, it could reread the diary and immediately regain situational awareness. Without it, the agent would sometimes suggest approaches we had already tried and rejected. It became a knowledge base. “Why did we choose approach A over B?” The answer was always in the diary. ### Break work down, commit constantly We also enforced small, concrete tasks. “Build the proxy server” was decomposed into steps like parsing requests, extracting JSON-RPC methods, and selecting a healthy node. Claude always knew what it was working on next. Finally, we required a commit after every completed phase. CI ran on every commit: formatting, Clippy, unit tests, and BDD scenarios. If anything failed, work stopped until it was fixed. This prevented the classic “I'll clean it up later” failure mode. By the end, we had more than 60 commits, each passing CI and usable as a rollback point. ## Phase 3: Implementation With the plan and guardrails in place, we mostly stayed out of the way. Claude wrote tests, implemented features, ran CI, and committed. We stepped in to clarify requirements or review decisions, but the agent did most of the work. **It felt less like using a tool and more like pairing with someone who is extremely fast at execution but relies on you for judgment calls.** We still hit issues like health check edge cases, Axum 0.8 route changes, WebSocket type mismatches and more, but the difference was that none of them lingered. Tests failed, the agent fixed them, and work continued. ## The result We shipped [Vixy](https://github.com/chainbound/vixy), a production-ready Ethereum proxy that monitors node health, handles automatic failover, proxies both HTTP and WebSocket traffic, and exposes status and metrics endpoints. Here are some numbers from the experiment: * 85 unit tests * 33 BDD scenarios (147 steps) * 17 integration tests against real Ethereum nodes * 60+ CI-passing commits * \~4,400 lines of Rust ## What we learned Three takeaways stood out: 1. LLMs are force multipliers, not replacements. We made the architectural decisions and set quality bars. Claude handled the execution. Think of it as a very fast engineer who needs clear direction and a plan. 2. Guardrails are mandatory. TDD, BDD, integration tests, small tasks, frequent commits, and written context are what made this work. Without them, results would have been unpredictable. 3. Good specs unlock autonomy. A clear architecture diagram and phased plan let the agent work productively for hours without constant steering. *** **Repository:** [https://github.com/chainbound/vixy](https://github.com/chainbound/vixy) **License:** MIT / Apache-2.0 ## P.S. Special shoutout to [Mert](https://x.com/mert) for the architecture diagram tip, and [Lwastuargo](https://x.com/lwastuargo) for reinforcing that guardrails are everything.