Skip to content

Commit 012b9c2

Browse files
dylanlottEvalir
andauthored
Adds bundle support to block builder (#8)
* WIP: adding bundle fetcher support * WIP: separating auth out into authenticator * WIP: fixing the cannot drop a runtime in a blocking context error * WIP: Fixes tokio runtime drop bug * wip: cleanup and remove imports * chore: fix dep error * chore: mark bundle poller test as it * feat: spawn bundle poller and handle exits * feat: ingest bundle transactions, drop bundle on failure This implements ingesting bundle transactions naively. It does not take into account the bundle "rules", so to say, which would make it a bit more complex. If a transaction fails to decode, the bundle is considered "invalid", and dropped. * chore: clippy * chore: comments * chore: clarify comment on signed orders * chore: dedup oauth, do not use spawn blocking * chore: remove unneeded async * chore: clean up authenticator * feat: track seen bundles to dedup * feat(`oauth`): caching, shareable authenticator (#19) * feat: caching, shareable authenticator * fix: actually spawn builder task lmao * chore: remove unnecesary mut selfs --------- Co-authored-by: evalir <[email protected]>
1 parent d444978 commit 012b9c2

12 files changed

+442
-53
lines changed

Cargo.toml

+4-1
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ aws-sdk-kms = "1.15.0"
3232
hex = { package = "const-hex", version = "1", default-features = false, features = [
3333
"alloc",
3434
] }
35+
36+
signet-types = { git = "ssh://[email protected]/init4tech/signet-node.git" }
37+
3538
serde = { version = "1.0.197", features = ["derive"] }
3639
tracing = "0.1.40"
3740

@@ -41,7 +44,7 @@ openssl = { version = "0.10", features = ["vendored"] }
4144
reqwest = { version = "0.11.24", features = ["blocking", "json"] }
4245
ruint = "1.12.1"
4346
serde_json = "1.0"
44-
thiserror = "1.0.58"
47+
thiserror = "1.0.68"
4548
tokio = { version = "1.36.0", features = ["full", "macros", "rt-multi-thread"] }
4649
tracing-subscriber = "0.3.18"
4750

bin/builder.rs

+18-6
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
use builder::config::BuilderConfig;
44
use builder::service::serve_builder_with_span;
5+
use builder::tasks::bundler::BundlePoller;
6+
use builder::tasks::oauth::Authenticator;
57
use builder::tasks::tx_poller::TxPoller;
68

79
use tokio::select;
@@ -11,8 +13,9 @@ async fn main() -> eyre::Result<()> {
1113
tracing_subscriber::fmt::try_init().unwrap();
1214
let span = tracing::info_span!("zenith-builder");
1315

14-
let config = BuilderConfig::load_from_env()?;
16+
let config = BuilderConfig::load_from_env()?.clone();
1517
let provider = config.connect_provider().await?;
18+
let authenticator = Authenticator::new(&config);
1619

1720
tracing::debug!(
1821
rpc_url = config.host_rpc_url.as_ref(),
@@ -23,23 +26,26 @@ async fn main() -> eyre::Result<()> {
2326
let zenith = config.connect_zenith(provider.clone());
2427

2528
let port = config.builder_port;
26-
2729
let tx_poller = TxPoller::new(&config);
30+
let bundle_poller = BundlePoller::new(&config, authenticator.clone()).await;
2831
let builder = builder::tasks::block::BlockBuilder::new(&config);
2932

3033
let submit = builder::tasks::submit::SubmitTask {
34+
authenticator: authenticator.clone(),
3135
provider,
3236
zenith,
3337
client: reqwest::Client::new(),
3438
sequencer_signer,
35-
config,
39+
config: config.clone(),
3640
};
3741

42+
let authenticator_jh = authenticator.spawn();
3843
let (submit_channel, submit_jh) = submit.spawn();
39-
let (build_channel, build_jh) = builder.spawn(submit_channel);
40-
let tx_poller_jh = tx_poller.spawn(build_channel.clone());
44+
let (tx_channel, bundle_channel, build_jh) = builder.spawn(submit_channel);
45+
let tx_poller_jh = tx_poller.spawn(tx_channel.clone());
46+
let bundle_poller_jh = bundle_poller.spawn(bundle_channel);
4147

42-
let server = serve_builder_with_span(build_channel, ([0, 0, 0, 0], port), span);
48+
let server = serve_builder_with_span(tx_channel, ([0, 0, 0, 0], port), span);
4349

4450
select! {
4551
_ = submit_jh => {
@@ -54,6 +60,12 @@ async fn main() -> eyre::Result<()> {
5460
_ = tx_poller_jh => {
5561
tracing::info!("tx_poller finished");
5662
}
63+
_ = bundle_poller_jh => {
64+
tracing::info!("bundle_poller finished");
65+
}
66+
_ = authenticator_jh => {
67+
tracing::info!("authenticator finished");
68+
}
5769
}
5870

5971
tracing::info!("shutting down");

src/config.rs

+4
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const BUILDER_REWARDS_ADDRESS: &str = "BUILDER_REWARDS_ADDRESS";
2626
const ROLLUP_BLOCK_GAS_LIMIT: &str = "ROLLUP_BLOCK_GAS_LIMIT";
2727
const TX_POOL_URL: &str = "TX_POOL_URL";
2828
const TX_POOL_POLL_INTERVAL: &str = "TX_POOL_POLL_INTERVAL";
29+
const AUTH_TOKEN_REFRESH_INTERVAL: &str = "AUTH_TOKEN_REFRESH_INTERVAL";
2930
const TX_POOL_CACHE_DURATION: &str = "TX_POOL_CACHE_DURATION";
3031
const OAUTH_CLIENT_ID: &str = "OAUTH_CLIENT_ID";
3132
const OAUTH_CLIENT_SECRET: &str = "OAUTH_CLIENT_SECRET";
@@ -82,6 +83,8 @@ pub struct BuilderConfig {
8283
pub oauth_token_url: String,
8384
/// OAuth audience for the builder.
8485
pub oauth_audience: String,
86+
/// The oauth token refresh interval in seconds.
87+
pub oauth_token_refresh_interval: u64,
8588
}
8689

8790
#[derive(Debug, thiserror::Error)]
@@ -159,6 +162,7 @@ impl BuilderConfig {
159162
oauth_authenticate_url: load_string(OAUTH_AUTHENTICATE_URL)?,
160163
oauth_token_url: load_string(OAUTH_TOKEN_URL)?,
161164
oauth_audience: load_string(OAUTH_AUDIENCE)?,
165+
oauth_token_refresh_interval: load_u64(AUTH_TOKEN_REFRESH_INTERVAL)?,
162166
})
163167
}
164168

src/service.rs

-1
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,6 @@ pub async fn ingest_raw_handler(
105105
) -> Result<Response, AppError> {
106106
let body = body.strip_prefix("0x").unwrap_or(&body);
107107
let buf = hex::decode(body).map_err(AppError::bad_req)?;
108-
109108
let envelope = TxEnvelope::decode_2718(&mut buf.as_slice()).map_err(AppError::bad_req)?;
110109

111110
ingest_handler(State(state), Json(envelope)).await

src/tasks/block.rs

+50-7
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
1-
use alloy::consensus::{SidecarBuilder, SidecarCoder, TxEnvelope};
1+
use alloy::{
2+
consensus::{SidecarBuilder, SidecarCoder, TxEnvelope},
3+
eips::eip2718::Decodable2718,
4+
};
25
use alloy_primitives::{keccak256, Bytes, B256};
6+
use alloy_rlp::Buf;
37
use std::{sync::OnceLock, time::Duration};
48
use tokio::{select, sync::mpsc, task::JoinHandle};
59
use tracing::Instrument;
610
use zenith_types::{encode_txns, Alloy2718Coder};
711

812
use crate::config::BuilderConfig;
913

14+
use super::bundler::Bundle;
15+
1016
#[derive(Debug, Default, Clone)]
1117
/// A block in progress.
1218
pub struct InProgressBlock {
@@ -57,6 +63,29 @@ impl InProgressBlock {
5763
self.transactions.push(tx.clone());
5864
}
5965

66+
/// Ingest a bundle into the in-progress block.
67+
/// Ignores Signed Orders for now.
68+
pub fn ingest_bundle(&mut self, bundle: Bundle) {
69+
tracing::info!(bundle = %bundle.id, "ingesting bundle");
70+
71+
let txs = bundle
72+
.bundle
73+
.bundle
74+
.txs
75+
.into_iter()
76+
.map(|tx| TxEnvelope::decode_2718(&mut tx.chunk()))
77+
.collect::<Result<Vec<_>, _>>();
78+
79+
if let Ok(txs) = txs {
80+
self.unseal();
81+
// extend the transactions with the decoded transactions.
82+
// As this builder does not provide bundles landing "top of block", its fine to just extend.
83+
self.transactions.extend(txs);
84+
} else {
85+
tracing::error!("failed to decode bundle. dropping");
86+
}
87+
}
88+
6089
/// Encode the in-progress block
6190
fn encode_raw(&self) -> &Bytes {
6291
self.seal();
@@ -102,10 +131,15 @@ impl BlockBuilder {
102131
pub fn spawn(
103132
self,
104133
outbound: mpsc::UnboundedSender<InProgressBlock>,
105-
) -> (mpsc::UnboundedSender<TxEnvelope>, JoinHandle<()>) {
134+
) -> (
135+
mpsc::UnboundedSender<TxEnvelope>,
136+
mpsc::UnboundedSender<Bundle>,
137+
JoinHandle<()>,
138+
) {
106139
let mut in_progress = InProgressBlock::default();
107140

108-
let (sender, mut inbound) = mpsc::unbounded_channel();
141+
let (tx_sender, mut tx_inbound) = mpsc::unbounded_channel();
142+
let (bundle_sender, mut bundle_inbound) = mpsc::unbounded_channel();
109143

110144
let mut sleep = Box::pin(tokio::time::sleep(Duration::from_secs(
111145
self.incoming_transactions_buffer,
@@ -131,9 +165,18 @@ impl BlockBuilder {
131165
// irrespective of whether we have any blocks to build.
132166
sleep.as_mut().reset(tokio::time::Instant::now() + Duration::from_secs(self.incoming_transactions_buffer));
133167
}
134-
item_res = inbound.recv() => {
135-
match item_res {
136-
Some(item) => in_progress.ingest_tx(&item),
168+
tx_resp = tx_inbound.recv() => {
169+
match tx_resp {
170+
Some(tx) => in_progress.ingest_tx(&tx),
171+
None => {
172+
tracing::debug!("upstream task gone");
173+
break
174+
}
175+
}
176+
}
177+
bundle_resp = bundle_inbound.recv() => {
178+
match bundle_resp {
179+
Some(bundle) => in_progress.ingest_bundle(bundle),
137180
None => {
138181
tracing::debug!("upstream task gone");
139182
break
@@ -146,6 +189,6 @@ impl BlockBuilder {
146189
.in_current_span(),
147190
);
148191

149-
(sender, handle)
192+
(tx_sender, bundle_sender, handle)
150193
}
151194
}

src/tasks/bundler.rs

+127
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
//! Bundler service responsible for polling and submitting bundles to the in-progress block.
2+
use std::time::{Duration, Instant};
3+
4+
pub use crate::config::BuilderConfig;
5+
use alloy_primitives::map::HashMap;
6+
use reqwest::Url;
7+
use serde::{Deserialize, Serialize};
8+
use signet_types::SignetEthBundle;
9+
use tokio::{sync::mpsc, task::JoinHandle};
10+
use tracing::debug;
11+
12+
use oauth2::TokenResponse;
13+
14+
use super::oauth::Authenticator;
15+
16+
#[derive(Debug, Clone, Serialize, Deserialize)]
17+
pub struct Bundle {
18+
pub id: String,
19+
pub bundle: SignetEthBundle,
20+
}
21+
22+
/// Response from the tx-pool containing a list of bundles.
23+
#[derive(Debug, Clone, Serialize, Deserialize)]
24+
pub struct TxPoolBundleResponse {
25+
pub bundles: Vec<Bundle>,
26+
}
27+
28+
pub struct BundlePoller {
29+
pub config: BuilderConfig,
30+
pub authenticator: Authenticator,
31+
pub seen_uuids: HashMap<String, Instant>,
32+
}
33+
34+
/// Implements a poller for the block builder to pull bundles from the tx cache.
35+
impl BundlePoller {
36+
/// Creates a new BundlePoller from the provided builder config.
37+
pub async fn new(config: &BuilderConfig, authenticator: Authenticator) -> Self {
38+
Self {
39+
config: config.clone(),
40+
authenticator,
41+
seen_uuids: HashMap::new(),
42+
}
43+
}
44+
45+
/// Fetches bundles from the transaction cache and returns the (oldest? random?) bundle in the cache.
46+
pub async fn check_bundle_cache(&mut self) -> eyre::Result<Vec<Bundle>> {
47+
let mut unique: Vec<Bundle> = Vec::new();
48+
49+
let bundle_url: Url = Url::parse(&self.config.tx_pool_url)?.join("bundles")?;
50+
let token = self.authenticator.fetch_oauth_token().await?;
51+
52+
// Add the token to the request headers
53+
let result = reqwest::Client::new()
54+
.get(bundle_url)
55+
.bearer_auth(token.access_token().secret())
56+
.send()
57+
.await?
58+
.error_for_status()?;
59+
60+
let body = result.bytes().await?;
61+
let bundles: TxPoolBundleResponse = serde_json::from_slice(&body)?;
62+
63+
bundles.bundles.iter().for_each(|bundle| {
64+
self.check_seen_bundles(bundle.clone(), &mut unique);
65+
});
66+
67+
Ok(unique)
68+
}
69+
70+
/// Checks if the bundle has been seen before and if not, adds it to the unique bundles list.
71+
fn check_seen_bundles(&mut self, bundle: Bundle, unique: &mut Vec<Bundle>) {
72+
self.seen_uuids.entry(bundle.id.clone()).or_insert_with(|| {
73+
// add to the set of unique bundles
74+
unique.push(bundle.clone());
75+
Instant::now() + Duration::from_secs(self.config.tx_pool_cache_duration)
76+
});
77+
}
78+
79+
/// Evicts expired bundles from the cache.
80+
fn evict(&mut self) {
81+
let expired_keys: Vec<String> = self
82+
.seen_uuids
83+
.iter()
84+
.filter_map(|(key, expiry)| {
85+
if expiry.elapsed().is_zero() {
86+
Some(key.clone())
87+
} else {
88+
None
89+
}
90+
})
91+
.collect();
92+
93+
for key in expired_keys {
94+
self.seen_uuids.remove(&key);
95+
}
96+
}
97+
98+
pub fn spawn(mut self, bundle_channel: mpsc::UnboundedSender<Bundle>) -> JoinHandle<()> {
99+
let handle: JoinHandle<()> = tokio::spawn(async move {
100+
loop {
101+
let bundle_channel = bundle_channel.clone();
102+
let bundles = self.check_bundle_cache().await;
103+
104+
match bundles {
105+
Ok(bundles) => {
106+
for bundle in bundles {
107+
let result = bundle_channel.send(bundle);
108+
if result.is_err() {
109+
tracing::debug!("bundle_channel failed to send bundle");
110+
}
111+
}
112+
}
113+
Err(err) => {
114+
debug!(?err, "error fetching bundles from tx-pool");
115+
}
116+
}
117+
118+
// evict expired bundles once every loop
119+
self.evict();
120+
121+
tokio::time::sleep(Duration::from_secs(self.config.tx_pool_poll_interval)).await;
122+
}
123+
});
124+
125+
handle
126+
}
127+
}

src/tasks/mod.rs

+2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
pub mod block;
2+
pub mod bundler;
3+
pub mod oauth;
24
pub mod submit;
35
pub mod tx_poller;

0 commit comments

Comments
 (0)