Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
377ede4
Unblock Gloas submissions through the existing bid-submission wire shape
0w3n-d Aug 25, 2026
4819897
Serve real Gloas bids and envelopes from the auctioneer's existing su…
0w3n-d Aug 25, 2026
e610e13
Refuse an unsupported fork on the SSZ validation path
0w3n-d Sep 1, 2026
9c851a8
Carry the builder's block access list on a Gloas submission
0w3n-d Sep 2, 2026
29d460f
Validate a Gloas/Amsterdam block in the simulation role
0w3n-d Sep 3, 2026
c90ca82
Build and submit a Gloas/Amsterdam block
0w3n-d Sep 4, 2026
06ca77e
Document Gloas support and add a testnet runbook
0w3n-d Sep 4, 2026
755cf26
Read the EIP-8282 predeploy addresses from ethrex, document glamsterd…
0w3n-d Sep 7, 2026
4be20af
Enable ethrex's `rayon` feature on the embedded node
0w3n-d Sep 8, 2026
f9bb4d9
Accept a payload_attributes event with no parent block number
0w3n-d Sep 9, 2026
b4dffb4
Skip the proposer duties write when there are no duties
0w3n-d Sep 9, 2026
3a27188
Install the crypto provider before the builder boots
0w3n-d Sep 10, 2026
3cacb63
Announce the payment in gwei and leave the enshrined value at zero
0w3n-d Sep 10, 2026
1618d77
Key payload attributes and bids by parent hash and beacon root
0w3n-d Sep 24, 2026
b566013
Encode the V1 dehydrated shape in the Gloas dehydrated decode test
0w3n-d Sep 25, 2026
2baef6f
Track Gloas proposer preferences and bind the bid request to the slot
0w3n-d Sep 18, 2026
12815da
Set the EIP-7928 post-tx index before requests and withdrawals
0w3n-d Sep 18, 2026
e5a0594
Cancel a slot's schedule when a newer head arrives
0w3n-d Sep 18, 2026
72436df
Carry EIP-8282 builder deposits and exits through the bid submission
0w3n-d Sep 18, 2026
51142d3
Bid the proposer's fee recipient and bind preferences to the slot's p…
0w3n-d Sep 18, 2026
35e0477
Give the builder a log directory so a redeploy keeps the record
0w3n-d Sep 18, 2026
6fc2c03
Refuse proposer preferences the slot's proposer did not sign
0w3n-d Sep 18, 2026
3c73efb
Do not resubmit a slot once its offsets have passed
0w3n-d Sep 18, 2026
7960eb2
Answer a no-bid request with a bare 204
0w3n-d Sep 19, 2026
f11197b
Build continuously from before the slot until the relay moves on
0w3n-d Sep 19, 2026
7b0d6c1
Keep a slot's payloads redeemable after the auction moves on
0w3n-d Sep 19, 2026
0faf2da
Refresh a slot's synthesized duty when preferences are resubmitted
0w3n-d Sep 19, 2026
051a2ea
Retry the payload reveal until our beacon node has the block
0w3n-d Sep 19, 2026
497672e
Wrap the Gloas bid in a fork-versioned response
0w3n-d Sep 19, 2026
28034f6
Hold the Gloas reveal until the attestation deadline
0w3n-d Sep 19, 2026
844e0b3
Withhold the payload when the proposer equivocates
0w3n-d Sep 19, 2026
962184c
Verify the proposer signed the block redeeming its bid
0w3n-d Sep 19, 2026
0e47b77
Log the request auth on both Gloas proposer endpoints
0w3n-d Sep 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 5 additions & 4 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,10 @@ ethereum_ssz_derive = "0.10"
# NOTE: the `ethrex` cmd-lib is deliberately NOT a dependency: it force-enables
# ethrex-crypto's `aws-lc-rs` feature, whose cc >=1.2.26 requirement conflicts with
# reth-mdbx-sys's exact `cc = 1.2.15` pin (via helix-simulator). The node is
# assembled from the library crates instead.
ethrex-blockchain = { git = "https://github.com/gattaca-com/ethrex", rev = "923cae9ec6156e485d9d768882bcb510f545d49a", default-features = false, features = ["secp256k1", "c-kzg", "metrics"] }
ethrex-common = { git = "https://github.com/gattaca-com/ethrex", rev = "923cae9ec6156e485d9d768882bcb510f545d49a", default-features = false, features = ["secp256k1", "c-kzg"] }
# assembled from the library crates instead, so `rayon` must be listed by hand:
# the parallel BAL execution path is cfg-gated on it.
ethrex-blockchain = { git = "https://github.com/gattaca-com/ethrex", rev = "923cae9ec6156e485d9d768882bcb510f545d49a", default-features = false, features = ["secp256k1", "c-kzg", "metrics", "rayon"] }
ethrex-common = { git = "https://github.com/gattaca-com/ethrex", rev = "923cae9ec6156e485d9d768882bcb510f545d49a", default-features = false, features = ["secp256k1", "c-kzg", "rayon"] }
ethrex-config = { git = "https://github.com/gattaca-com/ethrex", rev = "923cae9ec6156e485d9d768882bcb510f545d49a" }
# default features minus `aws-lc-rs` (see note above); P-256 verify uses the portable fallback
ethrex-crypto = { git = "https://github.com/gattaca-com/ethrex", rev = "923cae9ec6156e485d9d768882bcb510f545d49a", default-features = false, features = ["std", "kzg-rs", "secp256k1", "blst", "c-kzg"] }
Expand All @@ -66,7 +67,7 @@ ethrex-p2p = { git = "https://github.com/gattaca-com/ethrex", rev = "923cae9ec61
ethrex-rlp = { git = "https://github.com/gattaca-com/ethrex", rev = "923cae9ec6156e485d9d768882bcb510f545d49a" }
ethrex-rpc = { git = "https://github.com/gattaca-com/ethrex", rev = "923cae9ec6156e485d9d768882bcb510f545d49a" }
ethrex-storage = { git = "https://github.com/gattaca-com/ethrex", rev = "923cae9ec6156e485d9d768882bcb510f545d49a", features = ["rocksdb"] }
ethrex-vm = { git = "https://github.com/gattaca-com/ethrex", rev = "923cae9ec6156e485d9d768882bcb510f545d49a", default-features = false, features = ["secp256k1", "c-kzg"] }
ethrex-vm = { git = "https://github.com/gattaca-com/ethrex", rev = "923cae9ec6156e485d9d768882bcb510f545d49a", default-features = false, features = ["secp256k1", "c-kzg", "rayon"] }

eyre = "0.6.12"
clickhouse = "0.14.2"
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@

Helix is a MEV-Boost Relay designed with three key foundational principles: fast, simple, contained.

## Documentation

- [Architecture](docs/architecture.md)
- [Running on a Gloas testnet](docs/gloas-testnet.md)
- [Local development with Kurtosis](scripts/devnet/README.md)

## Audits

Audit conducted by Spearbit, with [Alex Stokes](https://github.com/ralexstokes) (EF) and [Matthias Seitz](https://github.com/mattsse) (Reth, Foundry, ethers-rs) leading as security researchers. See the report [here](audits/spearbit-audit.pdf).
Expand Down
3 changes: 2 additions & 1 deletion config.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ discord_webhook_url: null
blacklist_provider: null
is_submission_instance: true
is_registration_instance: true
logging: !File
logging:
type: File
dir_path: /app/logs
file_name: titan_relay.log
otlp_server: http://localhost:4317
Expand Down
1 change: 1 addition & 0 deletions crates/builder/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ tikv-jemallocator.workspace = true
tokio.workspace = true
tokio-util.workspace = true
tracing.workspace = true
tracing-appender.workspace = true
tracing-subscriber.workspace = true
uuid = { workspace = true, features = ["serde"] }
zstd.workspace = true
Expand Down
43 changes: 33 additions & 10 deletions crates/builder/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@ the relay must reach it through the simulator's `ssz_url`. Differences from
- The disallow list rejects interaction by effect -- a state change, or a
transaction addressed to a listed account. `crates/simulator` also rejects a
block that merely *reads* one, so it rejects strictly more blocks.
- Only Fulu (V5) and the relay-internal merged method are served.
- `/validate` serves Fulu and Gloas; `/validate_merged` serves Fulu only,
because merging protocol v1 cannot carry an Amsterdam block. A fork with no
shape here is refused with `501`, not `400`, so a helix limitation cannot
demote a builder.

## The building role

Expand All @@ -68,9 +71,14 @@ The block itself is ethrex's own payload machinery, with the builder as the
coinbase, so tips accrue to the builder and the bid is funded from them:

```
payout = tips + subsidy_wei - payout_gas_reserve * base_fee
payout = tips + subsidy_wei - payout_gas * base_fee
```

`payout_gas` is `payout_gas_reserve` (21000 by default), plus EIP-8037's state
gas for creating the recipient when it has no account yet -- which is the normal
case the first time a fee recipient is paid. See
[the Gloas testnet runbook](../../docs/gloas-testnet.md).

The block ends with a plain transfer of `payout` to the proposer's registered
fee recipient. `subsidy_wei` exists because the relay rejects a zero-value
block: without it an idle testnet would produce no bids at all, which is when
Expand All @@ -80,10 +88,17 @@ Gas for that transfer is held back from the fill by lowering ethrex's
`remaining_gas` before `fill_transactions` and restoring it afterwards.

The builder signs under the domain read from the beacon node's own spec and
genesis, never a compiled-in fork version. It builds at each
`submit_offsets_ms` point in the slot and submits only when the value beats
what it already sent for that slot and parent -- a new parent, after a re-org,
starts a fresh auction.
genesis, never a compiled-in fork version. It starts building `build_lead_ms`
before the slot begins, because the proposer asks for its bid at the slot
start, and then rebuilds and resubmits without pause, picking up whatever the
mempool has gained. It submits only when the value beats what it already sent
for that slot and parent -- a new parent, after a re-org, starts a fresh
auction. It stops when the relay reports that it has moved to the next bid
slot, or when the slot's own time runs out.

From Amsterdam the block also carries the EIP-7928 block access list ethrex
records and the EIP-7843 slot number, and the submission goes out in the Gloas
shape so the list travels with it.

What it does not do: no bundles or `eth_sendBundle`, no ordering of its own
(ethrex's tip-sorted fill), no cancellations, no bidding strategy (it always
Expand Down Expand Up @@ -167,20 +182,28 @@ On the relay side, add a merging builder to
with `ssz_url` set to this role's `ssz_addr` (see the repo-root
`config.example.yml`).

The embedded node is ethrex, pinned by rev in the workspace `Cargo.toml`.
Currently v26.0.0, which supports glamsterdam-devnet-8
(`--network plataberget`).

## Limitations

- Merging protocol v1 carries `ExecutionPayloadV3`; post-Amsterdam blocks
(EIP-7928 block access lists) are rejected as merge bases. The simulation
role has the same gap: the payload carries no block-access-list hash, so
Amsterdam needs a newer payload version.
(EIP-7928 block access lists) are rejected as merge bases, and the merged
validation route refuses Gloas for the same reason. The simulation and
building roles do handle Amsterdam: the block access list travels beside the
payload on a Gloas submission, and the header's list hash and slot number are
derived from it.
- The simulation role serves no JSON-RPC, so a relay without `ssz_url` cannot
use it.
- A blob is 128 KiB and crosses the stack several times in a debug build, which
overflows tokio's default worker stack. Release builds elide the copies; run
the simulation and building roles in release.
- The building role's payout uses a fixed `payout_gas_reserve`, 21000 by
default. A contract fee recipient needing more makes the payout fail and the
slot is skipped.
slot is skipped. From Amsterdam the builder adds EIP-8037's state gas for a
recipient that does not exist yet (183600 at the pinned ethrex revision),
because a fee recipient's first payment creates its account.
- The building role includes a blob transaction only when its sidecar reached
the node's mempool over devp2p; it does not rebuild sidecars.
- The base block's declared `block_hash` is trusted as the pool key; the wire
Expand Down
9 changes: 6 additions & 3 deletions crates/builder/build-config.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,13 @@ beacon_url: "http://localhost:3500"
subsidy_wei: 1000000000000000

# Gas held back from the fill for the trailing payout transaction. A contract
# fee recipient that needs more than this makes the payout fail.
# fee recipient that needs more than this makes the payout fail. From Amsterdam
# the builder adds EIP-8037's state gas on top when the recipient does not exist
# yet, so this covers the transfer alone.
payout_gas_reserve: 21000

extra_data: "helix-builder"

# Points into the slot, in milliseconds, at which to build and submit.
submit_offsets_ms: [500, 2000]
# How long before the slot starts to begin building, in milliseconds. From then
# the builder rebuilds and resubmits without pause until the relay moves on.
build_lead_ms: 2000
83 changes: 76 additions & 7 deletions crates/builder/src/building/assemble.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ use ethrex_common::{
},
};
use ethrex_crypto::native::NativeCrypto;
use ethrex_rlp::encode::RLPEncode;
use ethrex_storage::Store;
use thiserror::Error;

Expand All @@ -37,6 +38,8 @@ pub enum BuildError {
PayoutReverted,
#[error("the payout recipient is the builder itself")]
PayoutToSelf,
#[error("an Amsterdam block was built without a block access list")]
MissingBlockAccessList,
#[error("build failed: {0}")]
Internal(String),
}
Expand All @@ -53,6 +56,9 @@ pub struct BuiltBlock {
/// The changed accounts, for checking the payment the way the relay does.
#[allow(dead_code)]
pub account_updates: Vec<AccountUpdate>,
/// The encoded EIP-7928 list, from Amsterdam onwards. The header commits to
/// these exact bytes, so the submission has to carry them unchanged.
pub block_access_list: Option<Vec<u8>>,
/// Paid to the proposer by the trailing transaction, and the value the
/// `BidTrace` claims.
pub value: U256,
Expand Down Expand Up @@ -83,14 +89,18 @@ pub fn build(
return Err(BuildError::MissingParent);
}

// EIP-7843 puts the proposal slot in the header, and only from Amsterdam:
// setting it earlier would change every pre-Amsterdam block hash.
let is_amsterdam = store.get_chain_config().is_amsterdam_activated(slot.timestamp);

let args = BuildPayloadArgs {
parent: h256(slot.parent_hash),
timestamp: slot.timestamp,
fee_recipient: eaddr(builder),
random: h256(slot.prev_randao),
withdrawals: Some(slot.withdrawals.iter().map(ewithdrawal_lh).collect()),
beacon_root: Some(h256(slot.parent_beacon_block_root)),
slot_number: None,
slot_number: is_amsterdam.then_some(slot.slot),
version: 3,
elasticity_multiplier: ELASTICITY_MULTIPLIER,
// `create_payload` runs this through `calc_gas_limit`, which applies
Expand All @@ -107,16 +117,20 @@ pub fn build(
.map_err(|e| BuildError::Internal(format!("system operations: {e}")))?;

// `fill_transactions` spends every last drop of `remaining_gas`, so hold
// the payout's share back and restore it once the fill is done.
let reserve = config.payout_gas_reserve.min(ctx.remaining_gas);
// the payout's share back and restore it once the fill is done. The reserve
// is sized before the fill and the transaction after it, so a recipient the
// fill creates is not charged for twice.
let reserve = payout_gas_limit(config, is_amsterdam, recipient_exists(&mut ctx, slot)?)
.min(ctx.remaining_gas);
ctx.remaining_gas -= reserve;
blockchain
.fill_transactions(&mut ctx)
.map_err(|e| BuildError::Internal(format!("fill transactions: {e}")))?;
ctx.remaining_gas += reserve;

let payout_gas = payout_gas_limit(config, is_amsterdam, recipient_exists(&mut ctx, slot)?);
let base_fee = ctx.payload.header.base_fee_per_gas.unwrap_or_default();
let payout = payout_value(&ctx, config, base_fee)?;
let payout = payout_value(&ctx, config, payout_gas, base_fee)?;

let nonce = ctx
.vm
Expand All @@ -132,7 +146,7 @@ pub fn build(
.map_err(|e| BuildError::Internal(e.to_string()))?
.info
.balance;
let gas_cost = EU256::from(config.payout_gas_reserve) * EU256::from(base_fee);
let gas_cost = EU256::from(payout_gas) * EU256::from(base_fee);
if balance < eu256(payout) + gas_cost {
return Err(BuildError::PayoutUnaffordable);
}
Expand All @@ -143,7 +157,7 @@ pub fn build(
nonce,
slot.proposer_fee_recipient,
payout,
config.payout_gas_reserve,
payout_gas,
base_fee as u128,
)?;
let sender = payout_tx
Expand All @@ -159,6 +173,19 @@ pub fn build(
return Err(BuildError::PayoutReverted);
}

// EIP-7928: the requests and withdrawals phase records under index n+1, and every
// withdrawal recipient counts as touched. Mirrors ethrex's `build_payload`.
if is_amsterdam {
let post_tx_index =
u32::try_from(ctx.payload.body.transactions.len() + 1).unwrap_or(u32::MAX);
ctx.vm.set_bal_index(post_tx_index);
if let Some(recorder) = ctx.vm.db.bal_recorder_mut() &&
let Some(withdrawals) = &ctx.payload.body.withdrawals
{
recorder.extend_touched_addresses(withdrawals.iter().map(|w| w.address));
}
}

blockchain
.extract_requests(&mut ctx)
.map_err(|e| BuildError::Internal(format!("extract requests: {e}")))?;
Expand All @@ -169,22 +196,64 @@ pub fn build(
.finalize_payload(&mut ctx)
.map_err(|e| BuildError::Internal(format!("finalize: {e}")))?;

// `finalize_payload` hashed this into the header, so the submission has to
// carry the same encoding rather than re-deriving one.
let block_access_list = ctx.block_access_list.as_ref().map(|bal| bal.encode_to_vec());
if is_amsterdam && block_access_list.is_none() {
return Err(BuildError::MissingBlockAccessList);
}

Ok(BuiltBlock {
block: ctx.payload,
blobs_bundle: ctx.blobs_bundle,
requests: ctx.requests.unwrap_or_default(),
account_updates: ctx.account_updates,
block_access_list,
value: payout,
})
}

/// Whether the payout recipient already has an account, read from in-block
/// state so a payment earlier in the same block counts.
fn recipient_exists(ctx: &mut PayloadBuildContext, slot: &SlotContext) -> Result<bool, BuildError> {
Ok(ctx
.vm
.db
.get_account(eaddr(slot.proposer_fee_recipient))
.map_err(|e| BuildError::Internal(e.to_string()))?
.info !=
Default::default())
}

/// The gas the payout transaction needs. `payout_gas_reserve` covers the
/// transfer; paying an address for the first time also creates it, and from
/// Amsterdam that costs state gas (EIP-8037). A fee recipient's first payment
/// is exactly that case, and it is the normal case on a fresh testnet, so the
/// reserve has to cover it or every block is lost.
fn payout_gas_limit(config: &BuildingConfig, is_amsterdam: bool, recipient_exists: bool) -> u64 {
if is_amsterdam && !recipient_exists {
config.payout_gas_reserve + new_account_state_gas()
} else {
config.payout_gas_reserve
}
}

/// EIP-8037's charge for the state a new account occupies. Read from ethrex so
/// it cannot drift from the rules the simulator enforces.
fn new_account_state_gas() -> u64 {
use ethrex_levm::gas_cost::{STATE_BYTES_PER_NEW_ACCOUNT, cost_per_state_byte};
// `cost_per_state_byte` ignores its argument at this ethrex revision.
STATE_BYTES_PER_NEW_ACCOUNT * cost_per_state_byte(0)
}

/// Tips earned plus the subsidy, less the gas the payout itself will burn.
fn payout_value(
ctx: &PayloadBuildContext,
config: &BuildingConfig,
payout_gas: u64,
base_fee: u64,
) -> Result<U256, BuildError> {
let gas_cost = EU256::from(config.payout_gas_reserve) * EU256::from(base_fee);
let gas_cost = EU256::from(payout_gas) * EU256::from(base_fee);
let funded = ctx.block_value + EU256::from(config.subsidy_wei);
let payout = funded.checked_sub(gas_cost).ok_or(BuildError::NoPayout)?;
if payout.is_zero() {
Expand Down
Loading
Loading