diff --git a/Cargo.lock b/Cargo.lock index eb6bdd516..75df6ad01 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9705,6 +9705,23 @@ dependencies = [ "rand_core 0.3.1", ] +[[package]] +name = "reactive-bidding-service" +version = "0.1.0" +dependencies = [ + "alloy-primitives 1.4.1", + "bid-scraper", + "eyre", + "parking_lot", + "serde", + "serde_json", + "tokio", + "tokio-util", + "toml 0.8.23", + "tracing", + "tracing-subscriber 0.3.22", +] + [[package]] name = "recvmsg" version = "1.0.0" diff --git a/Cargo.toml b/Cargo.toml index 92ef5359d..4d4813c9d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ members = [ "crates/sysperf", "crates/test-relay", "crates/bid-scraper", + "crates/reactive-bidding-service", ] default-members = [ "crates/rbuilder", diff --git a/crates/bid-scraper/config.toml b/crates/bid-scraper/config.toml index 5e11729ec..6044d4810 100644 --- a/crates/bid-scraper/config.toml +++ b/crates/bid-scraper/config.toml @@ -1,41 +1,66 @@ log_json = true -log_level = "info" +log_level = "debug" log_color = false publisher_url ="tcp://0.0.0.0:5555" +# +#[[publishers]] +#type = "relay-bids" +#name = "relay-bids-1" +#relays_file = "/home/daniel/fb/rbuilder/crates/bid-scraper/relays.json" +#eth_provider_uri = "ws://localhost:8545" +#request_interval_s = 1.0 +#time_offset_index = 0 +#time_offset_count = 3 +#request_start_s = 5 +# +# +#[[publishers]] +#type = "relay-headers" +#name = "relay-headers-1" +#beacon_node_uri = "http://localhost:3500" +#relays_file = "/home/daniel/fb/rbuilder/crates/bid-scraper/relays.json" +#eth_provider_uri = "ws://127.0.0.1:8545" +#request_interval_s = 1.0 +#time_offset_index = 0 +#time_offset_count = 3 +#request_start_s = 5 -[[publishers]] -type = "relay-bids" -name = "relay-bids-1" -relays_file = "/home/daniel/fb/rbuilder/crates/bid-scraper/relays.json" -eth_provider_uri = "ws://localhost:8545" -request_interval_s = 1.0 -time_offset_index = 0 -time_offset_count = 3 -request_start_s = 5 - - -[[publishers]] -type = "relay-headers" -name = "relay-headers-1" -beacon_node_uri = "http://localhost:3500" -relays_file = "/home/daniel/fb/rbuilder/crates/bid-scraper/relays.json" -eth_provider_uri = "ws://127.0.0.1:8545" -request_interval_s = 1.0 -time_offset_index = 0 -time_offset_count = 3 -request_start_s = 5 - -[[publishers]] -type = "ultrasound-ws" -name = "ultrasound-us" -ultrasound_url = "ws://relay-builders-eu.ultrasound.money/ws/v1/top_bid" -relay_name = "ultrasound-us" +# A more effiecient connection can be achieved by following the docs here: https://docs.ultrasound.money/builders/direct-auction-connections +#[[publishers]] +#type = "ultrasound-ws" +#name = "ultrasound-eu" +#ultrasound_url = "ws://relay-builders-eu.ultrasound.money/ws/v1/top_bid" +#relay_name = "ultrasound-eu" +# Optional headers for ultrasound builder-direct endpoint (leave unset for public WS) +# builder_id = "env:ULTRASOUND_BUILDER_ID" +# api_token = "env:ULTRASOUND_API_TOKEN" +#[[publishers]] +#type = "ultrasound-ws" +#name = "ultrasound-us" +#ultrasound_url = "ws://relay-builders-us.ultrasound.money/ws/v1/top_bid" +#relay_name = "ultrasound-us" +# +# +#[[publishers]] +#type = "ultrasound-ws" +#name = "ultrasound-jp" +#ultrasound_url = "ws://relay-builders-jp.ultrasound.money/ws/v1/top_bid" +#relay_name = "ultrasound-jp" +# Titan WS requires builder access (0.10% of blocks, 0.1 ETH collateral) [[publishers]] -type = "bloxroute-ws" -name = "bloxroute" -bloxroute_url = "wss://mev-eth.blxrbdn.com/ws" -relay_name = "bloxroute" -auth_header = "env:BLOXROUTE_AUTH_HEADER" +type = "titan-ws" +name = "titan" +titan_url = "wss://us-global.titanrelay.xyz/builder/top_bid" +relay_name = "titan-ws" +api_token = "env:TITAN_API_TOKEN" +# +## +#[[publishers]] +#type = "bloxroute-ws" +#name = "bloxroute" +#bloxroute_url = "wss://mev-eth.blxrbdn.com/ws" +#relay_name = "bloxroute" +#auth_header = "env:BLOXROUTE_AUTH_HEADER" diff --git a/crates/bid-scraper/src/titan_ws_publisher.rs b/crates/bid-scraper/src/titan_ws_publisher.rs index 6877fef64..02709447a 100644 --- a/crates/bid-scraper/src/titan_ws_publisher.rs +++ b/crates/bid-scraper/src/titan_ws_publisher.rs @@ -40,11 +40,11 @@ impl ConnectionHandler for TitanWsConnectionHandler { } fn configure_request(&self, request: &mut Request<()>) -> eyre::Result<()> { let headers = request.headers_mut(); - let api_token_header_value = tokio_tungstenite::tungstenite::http::HeaderValue::from_str( + let api_key_header_value = tokio_tungstenite::tungstenite::http::HeaderValue::from_str( &self.cfg.api_token.value()?, ) - .wrap_err("Invalid header value for 'X-Api-Token'")?; - headers.insert("X-Api-Token", api_token_header_value); + .wrap_err("Invalid header value for 'x-api-key'")?; + headers.insert("x-api-key", api_key_header_value); Ok(()) } diff --git a/crates/bid-scraper/src/ws_publisher.rs b/crates/bid-scraper/src/ws_publisher.rs index 540a2bf95..3491255bd 100644 --- a/crates/bid-scraper/src/ws_publisher.rs +++ b/crates/bid-scraper/src/ws_publisher.rs @@ -50,22 +50,23 @@ where } pub async fn run(self) { + let url = self.handler.url(); if let Err(err) = self.run_with_error().await { - error!(err=?err, "UltrasoundWs failed"); + error!(err=?err, url=%url, "WebSocket publisher failed"); } } async fn run_with_error(self) -> eyre::Result<()> { - let mut request = self - .handler - .url() + let url = self.handler.url(); + let mut request = url + .clone() .into_client_request() .wrap_err("Unable to create request")?; self.handler.configure_request(&mut request)?; let (ws_stream, _) = timeout(RPC_TIMEOUT, tokio_tungstenite::connect_async(request)) .await - .wrap_err("timeout when connecting to ultrasound")? - .wrap_err("unable to connect to ultrasound")?; + .wrap_err_with(|| format!("timeout when connecting to {}", url))? + .wrap_err_with(|| format!("unable to connect to {}", url))?; let (mut write, mut read) = ws_stream.split(); self.handler.init_connection(&mut write, &mut read).await?; diff --git a/crates/rbuilder/src/live_builder/block_output/best_slot_bid.rs b/crates/rbuilder/src/live_builder/block_output/best_slot_bid.rs new file mode 100644 index 000000000..10f3fd568 --- /dev/null +++ b/crates/rbuilder/src/live_builder/block_output/best_slot_bid.rs @@ -0,0 +1,239 @@ +use std::collections::HashSet; + +use alloy_primitives::U256; +use alloy_rpc_types_beacon::BlsPublicKey; +use parking_lot::RwLock; + +use super::bidding_service_interface::ScrapedRelayBlockBidWithStats; + +/// Counter bid value for non-whitelisted builders (0.0005 ETH in wei) +pub const NON_WHITELISTED_COUNTER_BID: U256 = U256::from_limbs([500_000_000_000_000u64, 0, 0, 0]); + +/// Holds the best current bid for a block slot +#[derive(Debug, Clone)] +pub struct BestSlotBid { + /// The slot this bid is for + pub slot: u64, + /// The block number + pub block: u64, + /// The best bid value seen + pub value: U256, + /// The builder who submitted the best bid + pub builder_pubkey: Option, + /// Full bid details + pub bid: ScrapedRelayBlockBidWithStats, +} + +impl BestSlotBid { + pub fn new(bid: ScrapedRelayBlockBidWithStats) -> Self { + Self { + slot: bid.bid.slot_number, + block: bid.bid.block_number, + value: bid.bid.value, + builder_pubkey: bid.bid.builder_pubkey, + bid, + } + } + + /// Returns true if this bid is better (higher value) than another + pub fn is_better_than(&self, other: &BestSlotBid) -> bool { + self.value > other.value + } +} + +/// Evaluates counter bids based on builder whitelist status +pub trait CounterBidEvaluator: Send + Sync { + /// Evaluate what counter bid to make against the current best bid + /// Returns the counter bid amount + fn evaluate_counter_bid(&self, best_bid: &BestSlotBid) -> U256; + + /// Check if a builder is whitelisted + fn is_whitelisted(&self, builder_pubkey: Option<&BlsPublicKey>) -> bool; +} + +/// Counter bid evaluator that returns 0.0005 ETH for non-whitelisted builders +pub struct WhitelistCounterBidEvaluator { + whitelisted_builders: HashSet, +} + +impl WhitelistCounterBidEvaluator { + pub fn new(whitelisted_builders: HashSet) -> Self { + Self { + whitelisted_builders, + } + } + + pub fn from_vec(builders: Vec) -> Self { + Self { + whitelisted_builders: builders.into_iter().collect(), + } + } +} + +impl CounterBidEvaluator for WhitelistCounterBidEvaluator { + fn evaluate_counter_bid(&self, best_bid: &BestSlotBid) -> U256 { + if self.is_whitelisted(best_bid.builder_pubkey.as_ref()) { + // Whitelisted builder - no counter bid + U256::ZERO + } else { + // Non-whitelisted builder - counter bid of 0.0005 ETH + NON_WHITELISTED_COUNTER_BID + } + } + + fn is_whitelisted(&self, builder_pubkey: Option<&BlsPublicKey>) -> bool { + builder_pubkey.is_some_and(|pk| self.whitelisted_builders.contains(pk)) + } +} + +/// Thread-safe tracker for the best bid per slot +pub struct BestSlotBidTracker { + current_best: RwLock>, + counter_bid_evaluator: Box, +} + +impl BestSlotBidTracker { + pub fn new(counter_bid_evaluator: Box) -> Self { + Self { + current_best: RwLock::new(None), + counter_bid_evaluator, + } + } + + /// Update with a new bid, keeping only if it's the best for the current slot + pub fn update(&self, bid: ScrapedRelayBlockBidWithStats) { + let new_bid = BestSlotBid::new(bid); + let mut guard = self.current_best.write(); + + match guard.as_ref() { + Some(existing) => { + // Only update if same slot and better value, or newer slot + if new_bid.slot > existing.slot + || (new_bid.slot == existing.slot && new_bid.is_better_than(existing)) + { + *guard = Some(new_bid); + } + } + None => { + *guard = Some(new_bid); + } + } + } + + /// Get the current best bid for a specific slot + pub fn get_best_for_slot(&self, slot: u64) -> Option { + self.current_best + .read() + .as_ref() + .filter(|b| b.slot == slot) + .cloned() + } + + /// Get the current best bid regardless of slot + pub fn get_current_best(&self) -> Option { + self.current_best.read().clone() + } + + /// Evaluate the counter bid for the current best bid + pub fn evaluate_counter_bid(&self, slot: u64) -> Option { + self.get_best_for_slot(slot) + .map(|best| self.counter_bid_evaluator.evaluate_counter_bid(&best)) + } + + /// Check if the top bidder for a slot is whitelisted + pub fn is_top_bidder_whitelisted(&self, slot: u64) -> Option { + self.get_best_for_slot(slot) + .map(|best| self.counter_bid_evaluator.is_whitelisted(best.builder_pubkey.as_ref())) + } + + /// Clear the current best bid (e.g., on slot transition) + pub fn clear(&self) { + *self.current_best.write() = None; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::B256; + use bid_scraper::types::{PublisherType, ScrapedRelayBlockBid}; + use time::OffsetDateTime; + + fn make_pubkey(id: u8) -> BlsPublicKey { + let mut bytes = [0u8; 48]; + bytes[0] = id; + bytes.into() + } + + fn make_bid(slot: u64, value: u64, builder_pubkey: Option) -> ScrapedRelayBlockBidWithStats { + ScrapedRelayBlockBidWithStats { + bid: ScrapedRelayBlockBid { + seen_time: 0.0, + publisher_name: "test-publisher".to_string(), + publisher_type: PublisherType::RelayBids, + relay_time: None, + relay_name: "test".to_string(), + value: U256::from(value), + slot_number: slot, + block_number: 100, + block_hash: B256::ZERO, + parent_hash: B256::ZERO, + builder_pubkey, + extra_data: None, + fee_recipient: None, + proposer_fee_recipient: None, + gas_used: None, + optimistic_submission: None, + }, + creation_time: OffsetDateTime::now_utc(), + } + } + + #[test] + fn test_counter_bid_non_whitelisted() { + let whitelisted = make_pubkey(1); + let evaluator = WhitelistCounterBidEvaluator::from_vec(vec![whitelisted]); + let tracker = BestSlotBidTracker::new(Box::new(evaluator)); + + let non_whitelisted = make_pubkey(2); + tracker.update(make_bid(1, 1_000_000, Some(non_whitelisted))); + let counter = tracker.evaluate_counter_bid(1).unwrap(); + assert_eq!(counter, NON_WHITELISTED_COUNTER_BID); + } + + #[test] + fn test_counter_bid_whitelisted() { + let whitelisted = make_pubkey(1); + let evaluator = WhitelistCounterBidEvaluator::from_vec(vec![whitelisted]); + let tracker = BestSlotBidTracker::new(Box::new(evaluator)); + + tracker.update(make_bid(1, 1_000_000, Some(whitelisted))); + let counter = tracker.evaluate_counter_bid(1).unwrap(); + assert_eq!(counter, U256::ZERO); + } + + #[test] + fn test_counter_bid_no_builder_pubkey() { + let whitelisted = make_pubkey(1); + let evaluator = WhitelistCounterBidEvaluator::from_vec(vec![whitelisted]); + let tracker = BestSlotBidTracker::new(Box::new(evaluator)); + + tracker.update(make_bid(1, 1_000_000, None)); + let counter = tracker.evaluate_counter_bid(1).unwrap(); + assert_eq!(counter, NON_WHITELISTED_COUNTER_BID); + } + + #[test] + fn test_best_bid_tracking() { + let evaluator = WhitelistCounterBidEvaluator::from_vec(vec![]); + let tracker = BestSlotBidTracker::new(Box::new(evaluator)); + + tracker.update(make_bid(1, 100, Some(make_pubkey(1)))); + tracker.update(make_bid(1, 200, Some(make_pubkey(2)))); + tracker.update(make_bid(1, 150, Some(make_pubkey(3)))); // Lower, should not update + + let best = tracker.get_best_for_slot(1).unwrap(); + assert_eq!(best.value, U256::from(200)); + assert_eq!(best.builder_pubkey, Some(make_pubkey(2))); + } +} diff --git a/crates/rbuilder/src/live_builder/block_output/mod.rs b/crates/rbuilder/src/live_builder/block_output/mod.rs index 39d312ae5..b55abfec9 100644 --- a/crates/rbuilder/src/live_builder/block_output/mod.rs +++ b/crates/rbuilder/src/live_builder/block_output/mod.rs @@ -1,5 +1,7 @@ pub mod best_block_from_algorithms; +pub mod best_slot_bid; pub mod bidding_service_interface; +pub mod reactive_bidding_service; pub mod relay_submit; pub mod true_value_bidding_service; pub mod unfinished_block_processing; diff --git a/crates/rbuilder/src/live_builder/block_output/reactive_bidding_service.rs b/crates/rbuilder/src/live_builder/block_output/reactive_bidding_service.rs new file mode 100644 index 000000000..cf6dce143 --- /dev/null +++ b/crates/rbuilder/src/live_builder/block_output/reactive_bidding_service.rs @@ -0,0 +1,422 @@ +//! Reactive bidding service that tracks competition bids and outbids by increment. +//! +//! This bidding service uses MARKET-RELATIVE bidding: +//! 1. Tracks competition bids via `observe_relay_bids()` +//! 2. Bids: competition_bid + increment (capped at true_block_value) +//! 3. Respects max_bid cap and min_bid threshold +//! 4. Won't outbid protected builders (our own or whitelisted) + +use std::sync::Arc; + +use ahash::HashSet; +use alloy_primitives::{I256, U256}; +use alloy_rpc_types_beacon::BlsPublicKey; +use parking_lot::Mutex; +use rbuilder_primitives::mev_boost::MevBoostRelayID; +use time::OffsetDateTime; +use tokio_util::sync::CancellationToken; +use tracing::{info, trace, warn}; + +use super::bidding_service_interface::*; + +/// Default bid increment: 0.0001 ETH +const DEFAULT_INCREMENT: u64 = 100_000_000_000_000; // 0.0001 ETH in wei + +/// Default max bid: 1 ETH +const DEFAULT_MAX_BID: u64 = 1_000_000_000_000_000_000; // 1 ETH in wei + +/// Default min bid threshold: 0.001 ETH +const DEFAULT_MIN_BID: u64 = 1_000_000_000_000_000; // 0.001 ETH in wei + +/// A bidding service that uses MARKET-RELATIVE bidding: +/// 1. Tracks competition bids via observe_relay_bids() +/// 2. Bids: competition_bid + increment (capped at true_block_value) +/// 3. Respects max_bid cap and min_bid threshold +/// 4. Won't outbid protected builders +pub struct ReactiveBuilderFeeBiddingService { + /// Amount to outbid competition by + increment: U256, + /// Maximum bid we'll ever make + max_bid: U256, + /// Minimum competition bid to react to + min_bid: U256, + /// Protected builder pubkeys (won't outbid these) + protected_builders: HashSet, + /// When to start bidding relative to slot start + slot_delta_to_start_bidding: time::Duration, + /// Relay sets to bid to + relay_sets: Vec, + /// Shared state: best competition bid seen per slot + best_competition_bid: Arc>>, +} + +impl ReactiveBuilderFeeBiddingService { + /// Create with simple config (uses defaults for increment/max/min) + pub fn new( + _builder_fee: U256, // Kept for config compatibility, ignored in market-relative mode + slot_delta_to_start_bidding: time::Duration, + all_relays: RelaySet, + ) -> Self { + Self { + increment: U256::from(DEFAULT_INCREMENT), + max_bid: U256::from(DEFAULT_MAX_BID), + min_bid: U256::from(DEFAULT_MIN_BID), + protected_builders: HashSet::default(), + slot_delta_to_start_bidding, + relay_sets: vec![all_relays], + best_competition_bid: Arc::new(Mutex::new(None)), + } + } + + /// Create with full market-relative config + pub fn new_market_relative( + increment: U256, + max_bid: U256, + min_bid: U256, + protected_builders: Vec, + slot_delta_to_start_bidding: time::Duration, + all_relays: RelaySet, + ) -> Self { + Self { + increment, + max_bid, + min_bid, + protected_builders: protected_builders.into_iter().collect(), + slot_delta_to_start_bidding, + relay_sets: vec![all_relays], + best_competition_bid: Arc::new(Mutex::new(None)), + } + } + + /// Create with per-relay overrides + pub fn new_with_overrides( + _default_builder_fee: U256, + fee_overrides: ahash::HashMap, + slot_delta_to_start_bidding: time::Duration, + all_relays: RelaySet, + ) -> Self { + let mut default_relay_set: HashSet = + all_relays.relays().iter().cloned().collect(); + let mut relay_sets = Vec::new(); + + for (relay, _fee) in fee_overrides.iter() { + default_relay_set.remove(relay); + relay_sets.push(RelaySet::new(vec![relay.clone()])); + } + + if !default_relay_set.is_empty() { + relay_sets.push(RelaySet::new(default_relay_set.into_iter().collect())); + } + + Self { + increment: U256::from(DEFAULT_INCREMENT), + max_bid: U256::from(DEFAULT_MAX_BID), + min_bid: U256::from(DEFAULT_MIN_BID), + protected_builders: HashSet::default(), + slot_delta_to_start_bidding, + relay_sets, + best_competition_bid: Arc::new(Mutex::new(None)), + } + } + + /// Check if a builder is protected (won't outbid) + fn is_protected(&self, builder_pubkey: Option<&BlsPublicKey>) -> bool { + builder_pubkey.is_some_and(|pk| self.protected_builders.contains(pk)) + } +} + +impl BiddingService for ReactiveBuilderFeeBiddingService { + fn create_slot_bidder( + &self, + slot_block_id: SlotBlockId, + slot_timestamp: OffsetDateTime, + block_seal_handle: Box, + _cancel: CancellationToken, + ) -> Arc { + let bid_start_time = slot_timestamp + self.slot_delta_to_start_bidding; + info!( + slot = slot_block_id.slot, + block = slot_block_id.block, + increment = %self.increment, + max_bid = %self.max_bid, + bid_start_time = %bid_start_time, + "πŸ”§ Created market-relative slot bidder" + ); + Arc::new(ReactiveSlotBidder { + slot: slot_block_id.slot, + bid_start_time, + increment: self.increment, + max_bid: self.max_bid, + min_bid: self.min_bid, + protected_builders: self.protected_builders.clone(), + relay_sets: self.relay_sets.clone(), + best_competition_bid: self.best_competition_bid.clone(), + block_seal_handle, + last_bid: Arc::new(Mutex::new(None)), + }) + } + + fn relay_sets(&self) -> Vec { + self.relay_sets.clone() + } + + /// Store incoming competition bids for reactive bidding (non-blocking) + fn observe_relay_bids(&self, bid: ScrapedRelayBlockBidWithStats) { + if let Some(mut guard) = self.best_competition_bid.try_lock() { + if guard + .as_ref() + .map_or(true, |existing| bid.bid.value > existing.bid.value) + { + trace!( + slot = bid.bid.slot_number, + value = %bid.bid.value, + relay = %bid.bid.relay_name, + "Updated best competition bid" + ); + *guard = Some(bid); + } + } + } + + fn update_new_landed_blocks_detected(&self, _landed_blocks: &[LandedBlockInfo]) {} + + fn update_failed_reading_new_landed_blocks(&self) {} +} + +pub struct ReactiveSlotBidder { + slot: u64, + bid_start_time: OffsetDateTime, + increment: U256, + max_bid: U256, + min_bid: U256, + protected_builders: HashSet, + relay_sets: Vec, + best_competition_bid: Arc>>, + block_seal_handle: Box, + /// Track our last bid to avoid duplicate/lower bids + last_bid: Arc>>, +} + +impl ReactiveSlotBidder { + fn is_protected(&self, builder_pubkey: Option<&BlsPublicKey>) -> bool { + builder_pubkey.is_some_and(|pk| self.protected_builders.contains(pk)) + } + + fn format_builder(&self, pubkey: Option<&BlsPublicKey>) -> String { + pubkey + .map(|pk| { + let hex = format!("{:?}", pk); + if hex.len() > 14 { + format!("{}...{}", &hex[..10], &hex[hex.len() - 4..]) + } else { + hex + } + }) + .unwrap_or_else(|| "unknown".to_string()) + } +} + +impl SlotBidder for ReactiveSlotBidder { + fn notify_new_built_block(&self, block_descriptor: BuiltBlockDescriptorForSlotBidder) { + let true_block_value = block_descriptor.true_block_value; + + info!( + slot = self.slot, + true_block_value = %true_block_value, + "πŸ“¦ New block built, evaluating market-relative bid" + ); + + // Extract competition bid info + let competition_bid_info = self + .best_competition_bid + .try_lock() + .and_then(|guard| { + guard + .as_ref() + .filter(|b| b.bid.slot_number == self.slot) + .cloned() + }); + + let (competition_value, competition_builder, competition_relay) = competition_bid_info + .as_ref() + .map(|b| (b.bid.value, b.bid.builder_pubkey, b.bid.relay_name.clone())) + .unwrap_or_else(|| (U256::ZERO, None, "none".to_string())); + + // Check if top bidder is protected + if self.is_protected(competition_builder.as_ref()) { + info!( + slot = self.slot, + builder = %self.format_builder(competition_builder.as_ref()), + "⏭️ Skipping - top bidder is protected" + ); + return; + } + + // Check minimum threshold + if competition_value < self.min_bid && competition_value > U256::ZERO { + trace!( + slot = self.slot, + competition_value = %competition_value, + min_bid = %self.min_bid, + "⏭️ Skipping - competition below minimum threshold" + ); + return; + } + + // Calculate our bid: competition + increment, capped at true_block_value and max_bid + let target_bid = competition_value.saturating_add(self.increment); + let bid_value = target_bid.min(true_block_value).min(self.max_bid); + + // Check if bid would exceed max + if target_bid > self.max_bid { + warn!( + slot = self.slot, + target_bid = %target_bid, + max_bid = %self.max_bid, + "⏭️ Skipping - bid would exceed max cap" + ); + return; + } + + // Check if we can afford this bid (have enough block value) + if bid_value > true_block_value { + warn!( + slot = self.slot, + bid_value = %bid_value, + true_block_value = %true_block_value, + "⏭️ Skipping - insufficient block value" + ); + return; + } + + // Check if we already bid this amount or higher + { + let last = self.last_bid.lock(); + if let Some(last_bid) = *last { + if bid_value <= last_bid { + trace!( + slot = self.slot, + bid_value = %bid_value, + last_bid = %last_bid, + "⏭️ Skipping - already bid higher" + ); + return; + } + } + } + + // Update our last bid + *self.last_bid.lock() = Some(bid_value); + + // Calculate builder profit (what we retain) + let builder_profit = true_block_value.saturating_sub(bid_value); + + info!( + slot = self.slot, + "╔══════════════════════════════════════════════════════════════" + ); + info!( + slot = self.slot, + prev_bid_wei = %competition_value, + prev_bidder = %self.format_builder(competition_builder.as_ref()), + prev_relay = %competition_relay, + "β•‘ πŸ“Š COMPETITION: {} wei from {} via {}", + competition_value, + self.format_builder(competition_builder.as_ref()), + competition_relay + ); + info!( + slot = self.slot, + our_bid_wei = %bid_value, + increment = %self.increment, + builder_profit = %builder_profit, + "β•‘ 🎯 OUR BID: {} wei (comp + {} = {} profit)", + bid_value, + self.increment, + builder_profit + ); + info!( + slot = self.slot, + "β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•" + ); + + // Build competition context + let competition_context = competition_bid_info + .as_ref() + .map(|b| CompetitionBidContext { + seen_competition_bid: Some(BidWithInfo::from(&b.bid)), + triggering_bid_source_info: Some(BidSourceInfo::from(&b.bid)), + }) + .unwrap_or_else(CompetitionBidContext::no_competition_bid); + + // Calculate subsidy (negative = profit retained) + let subsidy = I256::try_from(builder_profit) + .unwrap_or(I256::ZERO) + .checked_neg() + .unwrap_or(I256::ZERO); + + let payout_info: Vec = self + .relay_sets + .iter() + .map(|relay_set| PayoutInfo { + relays: relay_set.clone(), + payout_tx_value: bid_value, + subsidy, + }) + .collect(); + + self.block_seal_handle.seal_bid(SlotBidderSealBidCommand { + block_id: block_descriptor.id, + trigger_creation_time: Some(OffsetDateTime::now_utc()), + competition_bid_context: competition_context, + payout_info, + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_reactive_bidding_service_creation() { + let relay_set = RelaySet::new(vec!["test-relay".to_string()]); + let service = ReactiveBuilderFeeBiddingService::new( + U256::from(10_000_000_000_000_000u64), // ignored in market-relative mode + time::Duration::milliseconds(-8000), + relay_set, + ); + + assert_eq!(service.relay_sets().len(), 1); + assert_eq!(service.increment, U256::from(DEFAULT_INCREMENT)); + } + + #[test] + fn test_market_relative_bid_calculation() { + let competition_value = U256::from(50_000_000_000_000_000u64); // 0.05 ETH + let increment = U256::from(100_000_000_000_000u64); // 0.0001 ETH + let true_block_value = U256::from(100_000_000_000_000_000u64); // 0.1 ETH + + // bid = competition + increment + let target_bid = competition_value.saturating_add(increment); + let bid_value = target_bid.min(true_block_value); + + assert_eq!(bid_value, U256::from(50_100_000_000_000_000u64)); // 0.0501 ETH + + // builder profit = true_value - bid + let builder_profit = true_block_value.saturating_sub(bid_value); + assert_eq!(builder_profit, U256::from(49_900_000_000_000_000u64)); // ~0.0499 ETH + } + + #[test] + fn test_bid_capped_at_true_value() { + let competition_value = U256::from(99_000_000_000_000_000u64); // 0.099 ETH + let increment = U256::from(10_000_000_000_000_000u64); // 0.01 ETH + let true_block_value = U256::from(100_000_000_000_000_000u64); // 0.1 ETH + + let target_bid = competition_value.saturating_add(increment); // 0.109 ETH + let bid_value = target_bid.min(true_block_value); // capped at 0.1 ETH + + assert_eq!(bid_value, true_block_value); + } +} diff --git a/crates/rbuilder/src/live_builder/config.rs b/crates/rbuilder/src/live_builder/config.rs index cd55f4b17..0c646c6bb 100644 --- a/crates/rbuilder/src/live_builder/config.rs +++ b/crates/rbuilder/src/live_builder/config.rs @@ -7,6 +7,7 @@ use super::{ bidding_service_interface::{ BidObserver, BiddingService, LandedBlockInfo, NullBidObserver, }, + reactive_bidding_service::ReactiveBuilderFeeBiddingService, relay_submit::{RelaySubmitSinkFactory, SubmissionConfig}, true_value_bidding_service::NewTrueBlockValueBiddingService, unfinished_block_processing::UnfinishedBuiltBlocksInputFactory, @@ -115,6 +116,17 @@ pub struct SubsidyConfig { pub value: String, } +/// Which bidding service to use +#[derive(Debug, Clone, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "kebab-case")] +pub enum BiddingServiceKind { + /// Bids true block value + subsidy (default) + #[default] + TrueValue, + /// Tracks competition bids and retains a builder fee + Reactive, +} + #[serde_as] #[derive(Debug, Clone, Deserialize, PartialEq)] #[serde(default, deny_unknown_fields)] @@ -128,6 +140,9 @@ pub struct Config { /// selected builder configurations pub builders: Vec, + /// Which bidding service to use: "true-value" (default) or "reactive" + pub bidding_service: BiddingServiceKind, + /// When the sample bidder (see TrueBlockValueBiddingService) will start bidding. /// Usually a negative number. pub slot_delta_to_start_bidding_ms: Option, @@ -136,6 +151,9 @@ pub struct Config { /// Overrides subsidy. #[serde(default)] pub subsidy_overrides: Vec, + + /// Builder fee to retain from block value when using reactive bidding (e.g., "0.01" for 0.01 ETH) + pub builder_fee: Option, } const DEFAULT_SLOT_DELTA_TO_START_BIDDING_MS: i64 = -8000; @@ -455,29 +473,50 @@ impl LiveBuilderConfig for Config { where P: StateProviderFactory + Clone + 'static, { - let subsidy = self.subsidy.clone(); let slot_delta_to_start_bidding_ms = time::Duration::milliseconds( self.slot_delta_to_start_bidding_ms .unwrap_or(DEFAULT_SLOT_DELTA_TO_START_BIDDING_MS), ); let all_relays_set = self.l1_config.relays_ids(); - let mut subsidy_overrides = HashMap::default(); - for subsidy_override in self.subsidy_overrides.iter() { - subsidy_overrides.insert( - subsidy_override.relay.clone(), - parse_ether(&subsidy_override.value)?, - ); - } - let bidding_service = Arc::new(NewTrueBlockValueBiddingService::new( - subsidy - .as_ref() - .map(|s| parse_ether(s)) - .unwrap_or(Ok(U256::ZERO))?, - subsidy_overrides, - slot_delta_to_start_bidding_ms, - all_relays_set.clone(), - )); + + let bidding_service: Arc = match self.bidding_service { + BiddingServiceKind::Reactive => { + let builder_fee = self + .builder_fee + .as_ref() + .map(|s| parse_ether(s)) + .unwrap_or(Ok(U256::ZERO))?; + info!( + builder_fee = %builder_fee, + "Using reactive bidding service with builder fee" + ); + Arc::new(ReactiveBuilderFeeBiddingService::new( + builder_fee, + slot_delta_to_start_bidding_ms, + all_relays_set.clone(), + )) + } + BiddingServiceKind::TrueValue => { + let subsidy = self.subsidy.clone(); + let mut subsidy_overrides = HashMap::default(); + for subsidy_override in self.subsidy_overrides.iter() { + subsidy_overrides.insert( + subsidy_override.relay.clone(), + parse_ether(&subsidy_override.value)?, + ); + } + Arc::new(NewTrueBlockValueBiddingService::new( + subsidy + .as_ref() + .map(|s| parse_ether(s)) + .unwrap_or(Ok(U256::ZERO))?, + subsidy_overrides, + slot_delta_to_start_bidding_ms, + all_relays_set.clone(), + )) + } + }; let (wallet_balance_watcher, _) = create_wallet_balance_watcher(provider.clone(), &self.base_config).await?; @@ -695,9 +734,11 @@ impl Default for Config { }), }, ], + bidding_service: BiddingServiceKind::default(), slot_delta_to_start_bidding_ms: None, subsidy: None, subsidy_overrides: Vec::new(), + builder_fee: None, } } } diff --git a/crates/reactive-bidding-service/Cargo.toml b/crates/reactive-bidding-service/Cargo.toml new file mode 100644 index 000000000..3e37a02e0 --- /dev/null +++ b/crates/reactive-bidding-service/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "reactive-bidding-service" +description = "Standalone service for scraping top bids from relay websockets" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[[bin]] +name = "reactive-bidding-service" +path = "src/main.rs" + +[dependencies] +alloy-primitives.workspace = true +bid-scraper.workspace = true +parking_lot.workspace = true +serde_json.workspace = true +tokio-util.workspace = true + +tokio = { workspace = true, default-features = false, features = [ + "rt", + "rt-multi-thread", + "macros", + "signal", + "sync", + "time", +] } +tracing.workspace = true +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +serde = { workspace = true, features = ["derive"] } +toml = "0.8" +eyre.workspace = true diff --git a/crates/reactive-bidding-service/config.toml b/crates/reactive-bidding-service/config.toml new file mode 100644 index 000000000..e6c830e73 --- /dev/null +++ b/crates/reactive-bidding-service/config.toml @@ -0,0 +1,58 @@ +# Reactive Bidding Service Configuration + +# Enable debug mode (logs all bids and exports to file) +debug = true + +# Export auction data to JSON lines file (only used if debug = true) +export_path = "/home/user/Projects/block_price_dynamics/data/auctions.jsonl" + +# ============================================================================ +# Bidding Strategy Configuration +# +# The strategy always evaluates incoming bids and sends counter-bids through +# a channel. The channel can be consumed by other crates (e.g., rbuilder). +# ============================================================================ +[bidding] +# Increment to add on top of the current best bid (in ETH) +# Based on observed data: median improvement is 0.000057 ETH, 90th percentile is 0.00058 ETH +# Using 0.0001 ETH (0.1 mETH) as a competitive default +increment_eth = 0.0001 + +# Maximum bid cap - never bid above this (in ETH) +max_bid_eth = 0.1 + +# Minimum bid threshold - don't bid if best bid is below this (in ETH) +min_bid_eth = 0.0001 + +# Our builder public keys (we won't outbid ourselves) +# Format: "0x" followed by 96-character hex string (48 bytes) +our_builders = [ + # "0xYOUR_BUILDER_PUBKEY_HERE" +] + +# Whitelisted builder public keys (we won't outbid these partners) +whitelisted_builders = [ + # "0xPARTNER_BUILDER_PUBKEY_HERE" +] + +# ============================================================================ +# Relay Configurations +# ============================================================================ +[[relays]] +name = "ultrasound-eu" +url = "ws://relay-builders-eu.ultrasound.money/ws/v1/top_bid" + +[[relays]] +name = "ultrasound-us" +url = "ws://relay-builders-us.ultrasound.money/ws/v1/top_bid" + +[[relays]] +name = "ultrasound-jp" +url = "ws://relay-builders-jp.ultrasound.money/ws/v1/top_bid" + +# For direct connection with auth (faster): +# [[relays]] +# name = "ultrasound-direct" +# url = "ws://relay-builders-eu.ultrasound.money/ws/v1/top_bid" +# builder_id = "your-builder-id" +# api_token = "your-api-token" diff --git a/crates/reactive-bidding-service/src/config.rs b/crates/reactive-bidding-service/src/config.rs new file mode 100644 index 000000000..41fe2d6cd --- /dev/null +++ b/crates/reactive-bidding-service/src/config.rs @@ -0,0 +1,150 @@ +//! Configuration for the reactive bidding service. + +use std::{collections::HashSet, fs, path::PathBuf}; + +use alloy_primitives::U256; +use eyre::{Context, Result}; +use serde::Deserialize; + +/// Main configuration for the reactive bidding service +#[derive(Debug, Clone, Deserialize)] +pub struct Config { + /// Relay configurations + pub relays: Vec, + + /// Path to export auction data (JSONL format) + pub export_path: Option, + + /// Enable debug mode (enables block recording) + #[serde(default)] + pub debug: bool, + + /// Bidding strategy configuration + #[serde(default)] + pub bidding: BiddingConfig, +} + +/// Configuration for a relay connection +#[derive(Debug, Clone, Deserialize)] +pub struct RelayConfig { + pub name: String, + pub url: String, + pub builder_id: Option, + pub api_token: Option, +} + +/// Bidding strategy configuration +#[derive(Debug, Clone, Deserialize)] +pub struct BiddingConfig { + /// Increment to add on top of the current best bid (in ETH) + /// Default: 0.0001 ETH (0.1 mETH) - based on observed market data + #[serde(default = "default_increment")] + pub increment_eth: f64, + + /// Maximum bid cap (in ETH) - never bid above this + /// Default: 0.1 ETH + #[serde(default = "default_max_bid")] + pub max_bid_eth: f64, + + /// Minimum bid (in ETH) - don't bid if best bid is below this + /// Default: 0.0001 ETH + #[serde(default = "default_min_bid")] + pub min_bid_eth: f64, + + /// Our builder public keys (hex strings with 0x prefix) + /// We won't outbid ourselves + #[serde(default)] + pub our_builders: Vec, + + /// Whitelisted builder public keys (hex strings with 0x prefix) + /// We won't outbid these builders (e.g., partners) + #[serde(default)] + pub whitelisted_builders: Vec, +} + +fn default_increment() -> f64 { + 0.0001 // 0.1 mETH - competitive based on observed data +} + +fn default_max_bid() -> f64 { + 0.1 // 0.1 ETH +} + +fn default_min_bid() -> f64 { + 0.0001 // 0.1 mETH +} + +impl Default for BiddingConfig { + fn default() -> Self { + Self { + increment_eth: default_increment(), + max_bid_eth: default_max_bid(), + min_bid_eth: default_min_bid(), + our_builders: Vec::new(), + whitelisted_builders: Vec::new(), + } + } +} + +impl BiddingConfig { + /// Convert increment to wei (U256) + pub fn increment_wei(&self) -> U256 { + eth_to_wei(self.increment_eth) + } + + /// Convert max bid to wei (U256) + pub fn max_bid_wei(&self) -> U256 { + eth_to_wei(self.max_bid_eth) + } + + /// Convert min bid to wei (U256) + pub fn min_bid_wei(&self) -> U256 { + eth_to_wei(self.min_bid_eth) + } + + /// Get set of all builder addresses we should not outbid + pub fn protected_builders(&self) -> HashSet { + let mut set = HashSet::new(); + for b in &self.our_builders { + set.insert(b.to_lowercase()); + } + for b in &self.whitelisted_builders { + set.insert(b.to_lowercase()); + } + set + } +} + +/// Convert ETH to wei +fn eth_to_wei(eth: f64) -> U256 { + let wei = (eth * 1e18) as u128; + U256::from(wei) +} + +/// Load configuration from a TOML file +pub fn load_config(path: &PathBuf) -> Result { + let content = fs::read_to_string(path).wrap_err("Failed to read config file")?; + toml::from_str(&content).wrap_err("Failed to parse config file") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_eth_to_wei() { + let wei = eth_to_wei(1.0); + assert_eq!(wei, U256::from(1_000_000_000_000_000_000u128)); + + let wei = eth_to_wei(0.0001); + assert_eq!(wei, U256::from(100_000_000_000_000u128)); + } + + #[test] + fn test_default_config() { + let config = BiddingConfig::default(); + assert_eq!(config.increment_eth, 0.0001); + assert_eq!(config.max_bid_eth, 0.1); + assert_eq!(config.min_bid_eth, 0.0001); + } +} diff --git a/crates/reactive-bidding-service/src/main.rs b/crates/reactive-bidding-service/src/main.rs new file mode 100644 index 000000000..91db74829 --- /dev/null +++ b/crates/reactive-bidding-service/src/main.rs @@ -0,0 +1,180 @@ +//! Reactive Bidding Service +//! +//! A service that monitors relay bid streams and can: +//! 1. Record auction data for analysis (debug mode) +//! 2. React to competitor bids with counter-bids (bidding mode) + +mod config; +mod recorder; +mod strategy; + +use std::{path::PathBuf, sync::Arc}; + +use bid_scraper::{ + bid_sender::{BidSender, BidSenderError}, + config::{NamedPublisherConfig, PublisherConfig}, + types::ScrapedRelayBlockBid, + ultrasound_ws_publisher::UltrasoundWsPublisherConfig, +}; +use eyre::Result; +use tokio::sync::broadcast; +use tokio_util::sync::CancellationToken; +use tracing::info; + +use crate::config::{load_config, RelayConfig}; +use crate::recorder::{create_recorder, BlockRecorder}; +use crate::strategy::{create_strategy, BiddingStrategy, CounterBidReceiver}; + +/// BidSender implementation that sends bids through a broadcast channel +pub struct BroadcastBidSender { + tx: broadcast::Sender, +} + +impl BroadcastBidSender { + pub fn new(tx: broadcast::Sender) -> Self { + Self { tx } + } +} + +impl BidSender for BroadcastBidSender { + fn send(&self, bid: ScrapedRelayBlockBid) -> Result<(), BidSenderError> { + let _ = self.tx.send(bid); + Ok(()) + } +} + +/// Convert config to bid-scraper's NamedPublisherConfig format +fn convert_relay_config(relays: &[RelayConfig]) -> Vec { + relays + .iter() + .map(|relay| NamedPublisherConfig { + name: relay.name.clone(), + publisher: PublisherConfig::UltrasoundWs(UltrasoundWsPublisherConfig { + ultrasound_url: relay.url.clone(), + relay_name: relay.name.clone(), + builder_id: relay.builder_id.clone(), + api_token: relay.api_token.clone(), + }), + }) + .collect() +} + +/// Process incoming bids - records and evaluates strategy +async fn process_bids( + mut bid_rx: broadcast::Receiver, + recorder: Option>, + strategy: Arc, +) { + info!("Starting bid processor..."); + + while let Ok(bid) = bid_rx.recv().await { + // Record bid if in debug mode + if let Some(ref rec) = recorder { + rec.record_bid(&bid); + } + + // Evaluate bidding strategy (sends to channel if counter-bid needed) + strategy.on_bid(&bid); + } +} + +/// Handle counter-bids from the strategy +/// This is where you would integrate with the builder to submit bids +async fn handle_counter_bids(mut rx: CounterBidReceiver) { + info!("Starting counter-bid handler..."); + + while let Some(counter_bid) = rx.recv().await { + // TODO: Integrate with rbuilder to submit the bid + // For now, just log it + tracing::debug!( + "πŸ“€ COUNTER-BID READY: slot={} block={} value={:.6} ETH via {} - {}", + counter_bid.slot, + counter_bid.block_number, + counter_bid.bid_value_eth, + counter_bid.relay, + counter_bid.reason + ); + } +} + +/// Public function to get a counter-bid receiver for external crates +/// Use this to integrate with rbuilder +pub fn get_counter_bid_channel( + config: crate::config::BiddingConfig, +) -> (Arc, CounterBidReceiver) { + create_strategy(config) +} + +#[tokio::main] +async fn main() -> Result<()> { + // Initialize logging + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::from_default_env() + .add_directive("reactive_bidding_service=info".parse().unwrap()) + .add_directive("bid_scraper=info".parse().unwrap()), + ) + .init(); + + // Load configuration + let config_path = std::env::args() + .nth(1) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("config.toml")); + + let config = load_config(&config_path)?; + + // Log startup info + info!("╔═══════════════════════════════════════════════════════════════"); + info!("β•‘ πŸš€ Reactive Bidding Service"); + info!("╠═══════════════════════════════════════════════════════════════"); + info!("β•‘ Relays: {}", config.relays.len()); + info!("β•‘ Debug mode: {}", config.debug); + info!("β•‘ Increment: {:.6} ETH", config.bidding.increment_eth); + info!("β•‘ Max bid: {:.4} ETH", config.bidding.max_bid_eth); + info!("β•‘ Min bid: {:.6} ETH", config.bidding.min_bid_eth); + info!("β•‘ Our builders: {}", config.bidding.our_builders.len()); + info!("β•‘ Whitelisted: {}", config.bidding.whitelisted_builders.len()); + if let Some(ref path) = config.export_path { + info!("β•‘ Export path: {}", path); + } + info!("β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•"); + + // Create broadcast channel for bids + let (bid_tx, bid_rx) = broadcast::channel::(1024); + + // Create the bid sender + let sender: Arc = Arc::new(BroadcastBidSender::new(bid_tx)); + + // Convert config and start bid scraper + let publishers = convert_relay_config(&config.relays); + let cancel = CancellationToken::new(); + + // Start the bid scraper + bid_scraper::bid_scraper::run(publishers, sender, cancel.clone()); + + // Create recorder (only if debug mode) + let recorder = create_recorder(config.debug, config.export_path.map(PathBuf::from)); + + // Create bidding strategy - always active, sends counter-bids through channel + let (strategy, counter_bid_rx) = create_strategy(config.bidding); + + // Spawn bid processor + let processor = tokio::spawn(process_bids(bid_rx, recorder, strategy)); + + // Spawn counter-bid handler + let counter_bid_handler = tokio::spawn(handle_counter_bids(counter_bid_rx)); + + // Wait for Ctrl+C + tokio::select! { + _ = tokio::signal::ctrl_c() => { + info!("Received Ctrl+C, shutting down..."); + cancel.cancel(); + } + } + + processor.abort(); + counter_bid_handler.abort(); + info!("Shutdown complete"); + Ok(()) +} diff --git a/crates/reactive-bidding-service/src/recorder.rs b/crates/reactive-bidding-service/src/recorder.rs new file mode 100644 index 000000000..d78e7c8ae --- /dev/null +++ b/crates/reactive-bidding-service/src/recorder.rs @@ -0,0 +1,225 @@ +//! Block recording module for debugging and analysis. +//! +//! This module tracks all bids per slot and exports detailed statistics +//! for offline analysis. Only active when debug mode is enabled. + +use std::{ + fs::OpenOptions, + io::Write, + path::PathBuf, + sync::Arc, + time::Instant, +}; + +use alloy_primitives::U256; +use bid_scraper::types::ScrapedRelayBlockBid; +use parking_lot::Mutex; +use serde::Serialize; +use tracing::info; + +/// A single bid point with timing information +#[derive(Debug, Clone, Serialize)] +pub struct BidPoint { + pub timestamp_ms: u64, + #[serde(serialize_with = "serialize_u256")] + pub value: U256, + pub value_eth: f64, + pub relay: String, + pub builder: Option, +} + +fn serialize_u256(val: &U256, serializer: S) -> Result +where + S: serde::Serializer, +{ + serializer.serialize_str(&val.to_string()) +} + +/// Exported auction data for analysis +#[derive(Debug, Clone, Serialize)] +pub struct SlotAuctionExport { + pub slot: u64, + pub block_number: u64, + pub duration_ms: u64, + pub total_bids: usize, + pub unique_builders: usize, + pub unique_relays: usize, + pub winning_value_eth: f64, + pub winning_builder: Option, + pub winning_relay: String, + pub min_value_eth: f64, + pub max_value_eth: f64, + pub price_range_eth: f64, + pub bids: Vec, +} + +/// Tracks all bids for a single slot +#[derive(Debug, Clone)] +pub struct SlotAuction { + pub slot: u64, + pub block_number: u64, + pub start_time: Instant, + pub bids: Vec, +} + +impl SlotAuction { + pub fn new(slot: u64, block_number: u64) -> Self { + Self { + slot, + block_number, + start_time: Instant::now(), + bids: Vec::new(), + } + } + + pub fn add_bid(&mut self, bid: &ScrapedRelayBlockBid) { + let elapsed_ms = self.start_time.elapsed().as_millis() as u64; + let value_eth = bid.value.to_string().parse::().unwrap_or(0.0) / 1e18; + self.bids.push(BidPoint { + timestamp_ms: elapsed_ms, + value: bid.value, + value_eth, + relay: bid.relay_name.clone(), + builder: bid.builder_pubkey.map(|b| format!("0x{}", b)), + }); + } + + pub fn best_bid(&self) -> Option<&BidPoint> { + self.bids.iter().max_by_key(|b| b.value) + } + + pub fn to_export(&self) -> SlotAuctionExport { + use std::collections::HashSet; + + let best = self.best_bid(); + let duration_ms = self.bids.last().map(|b| b.timestamp_ms).unwrap_or(0); + let unique_builders: HashSet<_> = self.bids.iter().filter_map(|b| b.builder.as_ref()).collect(); + let unique_relays: HashSet<_> = self.bids.iter().map(|b| &b.relay).collect(); + + let min_val = self.bids.iter().map(|b| b.value_eth).fold(f64::INFINITY, f64::min); + let max_val = self.bids.iter().map(|b| b.value_eth).fold(f64::NEG_INFINITY, f64::max); + + SlotAuctionExport { + slot: self.slot, + block_number: self.block_number, + duration_ms, + total_bids: self.bids.len(), + unique_builders: unique_builders.len(), + unique_relays: unique_relays.len(), + winning_value_eth: best.map(|b| b.value_eth).unwrap_or(0.0), + winning_builder: best.and_then(|b| b.builder.clone()), + winning_relay: best.map(|b| b.relay.clone()).unwrap_or_default(), + min_value_eth: if min_val.is_finite() { min_val } else { 0.0 }, + max_value_eth: if max_val.is_finite() { max_val } else { 0.0 }, + price_range_eth: if max_val.is_finite() && min_val.is_finite() { max_val - min_val } else { 0.0 }, + bids: self.bids.clone(), + } + } +} + +/// Block recorder that tracks auctions and exports data +pub struct BlockRecorder { + current_auction: Mutex>, + export_path: Option, +} + +impl BlockRecorder { + pub fn new(export_path: Option) -> Self { + Self { + current_auction: Mutex::new(None), + export_path, + } + } + + /// Process a new bid. Returns the finished auction if we moved to a new block. + pub fn record_bid(&self, bid: &ScrapedRelayBlockBid) -> Option { + let mut current = self.current_auction.lock(); + + // Check if we moved to a new block + let finished_auction = match current.as_ref() { + Some(auction) if bid.block_number > auction.block_number => current.take(), + _ => None, + }; + + // Add bid to current or new auction + match current.as_mut() { + Some(auction) if auction.block_number == bid.block_number => { + auction.add_bid(bid); + } + _ => { + let mut new_auction = SlotAuction::new(bid.slot_number, bid.block_number); + new_auction.add_bid(bid); + *current = Some(new_auction); + } + } + + // Export and print finished auction + if let Some(ref auction) = finished_auction { + self.export_auction(auction); + self.print_auction_summary(auction); + } + + finished_auction + } + + fn export_auction(&self, auction: &SlotAuction) { + if let Some(ref path) = self.export_path { + if let Err(e) = self.write_auction_json(auction, path) { + tracing::error!("Failed to export auction: {}", e); + } + } + } + + fn write_auction_json(&self, auction: &SlotAuction, path: &PathBuf) -> eyre::Result<()> { + let export = auction.to_export(); + let json = serde_json::to_string(&export)?; + + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(path)?; + + writeln!(file, "{}", json)?; + Ok(()) + } + + fn print_auction_summary(&self, auction: &SlotAuction) { + if auction.bids.is_empty() { + return; + } + + let best = auction.best_bid().unwrap(); + let builder_short = best + .builder + .as_ref() + .map(|b| format!("{}...{}", &b[..8], &b[b.len() - 4..])) + .unwrap_or_else(|| "unknown".to_string()); + + info!(""); + info!("╔═══════════════════════════════════════════════════════════════════════════════"); + info!("β•‘ πŸ“Š SLOT {} AUCTION (Block {})", auction.slot, auction.block_number); + info!("╠═══════════════════════════════════════════════════════════════════════════════"); + info!( + "β•‘ Total bids: {} | Duration: {}ms", + auction.bids.len(), + auction.bids.last().map(|b| b.timestamp_ms).unwrap_or(0) + ); + info!( + "β•‘ Winner: {} @ {:.6} ETH via {}", + builder_short, + best.value_eth, + best.relay + ); + info!("β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•"); + info!(""); + } +} + +/// Create a recorder if debug mode is enabled +pub fn create_recorder(debug: bool, export_path: Option) -> Option> { + if debug { + Some(Arc::new(BlockRecorder::new(export_path))) + } else { + None + } +} diff --git a/crates/reactive-bidding-service/src/strategy.rs b/crates/reactive-bidding-service/src/strategy.rs new file mode 100644 index 000000000..148c1b823 --- /dev/null +++ b/crates/reactive-bidding-service/src/strategy.rs @@ -0,0 +1,335 @@ +//! Bidding strategy module. +//! +//! Implements reactive bidding logic: +//! - Track the best bid across all relays +//! - If top bidder is not us/whitelisted, outbid by increment +//! - Respect max bid cap +//! - Send counter-bid commands through a channel for consumption by other crates + +use std::collections::HashSet; +use std::sync::Arc; + +use alloy_primitives::U256; +use bid_scraper::types::ScrapedRelayBlockBid; +use parking_lot::Mutex; +use tokio::sync::mpsc; +use tracing::{debug, info}; + +use crate::config::BiddingConfig; + +/// Current best bid state across all relays +#[derive(Debug, Clone)] +pub struct BestBidState { + pub slot: u64, + pub block_number: u64, + pub value: U256, + pub value_eth: f64, + pub builder: Option, + pub relay: String, +} + +/// A counter-bid command to be sent to the builder +#[derive(Debug, Clone)] +pub struct CounterBid { + /// Slot number + pub slot: u64, + /// Block number + pub block_number: u64, + /// Our counter-bid value in wei + pub bid_value: U256, + /// Our counter-bid value in ETH (for display) + pub bid_value_eth: f64, + /// The bid we're countering + pub countering_value: U256, + /// The builder we're outbidding + pub countering_builder: Option, + /// The relay this bid came from + pub relay: String, + /// Reason for this bid + pub reason: String, +} + +/// Receiver end for counter-bid commands +pub type CounterBidReceiver = mpsc::UnboundedReceiver; + +/// Sender end for counter-bid commands (internal use) +pub type CounterBidSender = mpsc::UnboundedSender; + +/// Reactive bidding strategy +/// +/// Always evaluates incoming bids and sends counter-bids through a channel +/// when appropriate. The channel can be consumed by other crates (e.g., rbuilder). +pub struct BiddingStrategy { + config: BiddingConfig, + protected_builders: HashSet, + current_best: Mutex>, + our_last_bid: Mutex>, + counter_bid_tx: CounterBidSender, +} + +impl BiddingStrategy { + /// Create a new bidding strategy and return it along with the counter-bid receiver + pub fn new(config: BiddingConfig) -> (Arc, CounterBidReceiver) { + let protected_builders = config.protected_builders(); + let (tx, rx) = mpsc::unbounded_channel(); + + info!( + "Bidding strategy initialized: increment={:.6} ETH, max={:.4} ETH, protected_builders={}", + config.increment_eth, + config.max_bid_eth, + protected_builders.len() + ); + + let strategy = Arc::new(Self { + config, + protected_builders, + current_best: Mutex::new(None), + our_last_bid: Mutex::new(None), + counter_bid_tx: tx, + }); + + (strategy, rx) + } + + /// Check if a builder address is protected (ours or whitelisted) + fn is_protected(&self, builder: &Option) -> bool { + match builder { + Some(addr) => self.protected_builders.contains(&addr.to_lowercase()), + None => false, + } + } + + /// Process a new bid - evaluates and sends counter-bid if appropriate + /// Returns true if a counter-bid was sent + pub fn on_bid(&self, bid: &ScrapedRelayBlockBid) -> bool { + let value_eth = bid.value.to_string().parse::().unwrap_or(0.0) / 1e18; + let builder = bid.builder_pubkey.map(|b| format!("0x{}", b)); + + let mut current_best = self.current_best.lock(); + + // Check if this is a new block - reset state + let is_new_block = current_best + .as_ref() + .map(|b| bid.block_number > b.block_number) + .unwrap_or(true); + + if is_new_block { + *self.our_last_bid.lock() = None; + } + + // Check if this bid is better than current best + let is_new_best = current_best + .as_ref() + .map(|b| bid.value > b.value && bid.block_number >= b.block_number) + .unwrap_or(true); + + if !is_new_best { + return false; + } + + // Update best bid state + let new_best = BestBidState { + slot: bid.slot_number, + block_number: bid.block_number, + value: bid.value, + value_eth, + builder: builder.clone(), + relay: bid.relay_name.clone(), + }; + *current_best = Some(new_best.clone()); + drop(current_best); + + // Evaluate and potentially send counter-bid + self.evaluate_and_send(&new_best) + } + + /// Evaluate whether we should counter-bid and send if appropriate + fn evaluate_and_send(&self, best: &BestBidState) -> bool { + // Check if top bidder is protected + if self.is_protected(&best.builder) { + tracing::trace!( + "Skip: top bidder {} is protected", + best.builder.as_deref().unwrap_or("unknown") + ); + return false; + } + + // Check if bid is above minimum threshold + if best.value < self.config.min_bid_wei() { + tracing::trace!( + "Skip: best bid {:.6} ETH below minimum", + best.value_eth + ); + return false; + } + + // Calculate our bid: current best + increment + let our_bid = best.value.saturating_add(self.config.increment_wei()); + let our_bid_eth = our_bid.to_string().parse::().unwrap_or(0.0) / 1e18; + + // Check max cap + if our_bid > self.config.max_bid_wei() { + tracing::trace!( + "Skip: calculated bid {:.6} ETH exceeds max cap", + our_bid_eth + ); + return false; + } + + // Check if we already bid this amount or higher + { + let last_bid = self.our_last_bid.lock(); + if let Some(last) = *last_bid { + if our_bid <= last { + tracing::trace!("Skip: already bid higher"); + return false; + } + } + } + + // Update our last bid + *self.our_last_bid.lock() = Some(our_bid); + + let builder_short = best + .builder + .as_ref() + .map(|b| { + if b.len() > 14 { + format!("{}...{}", &b[..10], &b[b.len() - 4..]) + } else { + b.clone() + } + }) + .unwrap_or_else(|| "unknown".to_string()); + + let reason = format!( + "Outbidding {} by +{:.6} ETH", + builder_short, + self.config.increment_eth + ); + + debug!( + "πŸ’° COUNTER-BID: slot={} block={} our_bid={:.6} ETH (vs {:.6} ETH from {} via {})", + best.slot, + best.block_number, + our_bid_eth, + best.value_eth, + builder_short, + best.relay + ); + + let counter_bid = CounterBid { + slot: best.slot, + block_number: best.block_number, + bid_value: our_bid, + bid_value_eth: our_bid_eth, + countering_value: best.value, + countering_builder: best.builder.clone(), + relay: best.relay.clone(), + reason, + }; + + // Send through channel - ignore error if receiver dropped + let _ = self.counter_bid_tx.send(counter_bid); + true + } + + /// Get the current best bid state + pub fn current_best(&self) -> Option { + self.current_best.lock().clone() + } + + /// Get the configuration + pub fn config(&self) -> &BiddingConfig { + &self.config + } +} + +/// Create a bidding strategy and return the counter-bid receiver +pub fn create_strategy(config: BiddingConfig) -> (Arc, CounterBidReceiver) { + BiddingStrategy::new(config) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::FixedBytes; + + fn make_bid(value_eth: f64, builder: Option<&str>) -> ScrapedRelayBlockBid { + let value_wei = (value_eth * 1e18) as u128; + ScrapedRelayBlockBid { + seen_time: 0.0, + relay_name: "test-relay".to_string(), + publisher_name: "test".to_string(), + value: U256::from(value_wei), + slot_number: 1000, + block_number: 2000, + block_hash: Default::default(), + parent_hash: Default::default(), + builder_pubkey: builder.map(|_| FixedBytes::default()), + proposer_fee_recipient: Default::default(), + proposer_pubkey: Default::default(), + optimistic_submission: false, + } + } + + #[tokio::test] + async fn test_counter_bid_sent() { + let config = BiddingConfig { + increment_eth: 0.0001, + max_bid_eth: 0.1, + min_bid_eth: 0.0001, + ..Default::default() + }; + let (strategy, mut rx) = BiddingStrategy::new(config); + + let bid = make_bid(0.01, None); + let sent = strategy.on_bid(&bid); + + assert!(sent, "Should have sent a counter-bid"); + + // Check the counter-bid was received + let counter_bid = rx.try_recv().expect("Should have received counter-bid"); + assert!(counter_bid.bid_value_eth > 0.01); + assert!(counter_bid.bid_value_eth < 0.0102); // increment is 0.0001 + } + + #[tokio::test] + async fn test_respects_max_cap() { + let config = BiddingConfig { + increment_eth: 0.0001, + max_bid_eth: 0.01, // Low max cap + min_bid_eth: 0.0001, + ..Default::default() + }; + let (strategy, mut rx) = BiddingStrategy::new(config); + + // Bid at max cap - counter-bid would exceed + let bid = make_bid(0.01, None); + let sent = strategy.on_bid(&bid); + + assert!(!sent, "Should not bid when it would exceed max cap"); + assert!(rx.try_recv().is_err(), "Should not have received counter-bid"); + } + + #[tokio::test] + async fn test_protected_builder_not_outbid() { + let config = BiddingConfig { + increment_eth: 0.0001, + max_bid_eth: 0.1, + min_bid_eth: 0.0001, + our_builders: vec!["0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000".to_string()], + ..Default::default() + }; + let (strategy, mut rx) = BiddingStrategy::new(config); + + // Bid from our builder + let bid = make_bid(0.01, Some("our_builder")); + let sent = strategy.on_bid(&bid); + + // The builder pubkey in make_bid is FixedBytes::default() which is all zeros + // Our protected list includes the all-zeros pubkey + assert!(!sent, "Should not outbid protected builder"); + assert!(rx.try_recv().is_err(), "Should not have received counter-bid"); + } +} diff --git a/examples/config/rbuilder/config-playground-local.toml b/examples/config/rbuilder/config-playground-local.toml new file mode 100644 index 000000000..ba8bec54d --- /dev/null +++ b/examples/config/rbuilder/config-playground-local.toml @@ -0,0 +1,91 @@ +# rbuilder config for use with builder-playground +# +# Usage: +# 1. Start playground: ./builder-playground start l1 --use-native-reth --secondary-el 9551 +# 2. Wait for "All services are healthy!" +# 3. Run rbuilder: cargo run --release --bin rbuilder -- run examples/config/rbuilder/config-playground-local.toml + +log_json = false +log_level = "info,rbuilder=debug" + +# Telemetry +full_telemetry_server_port = 6060 +full_telemetry_server_ip = "0.0.0.0" + +# Chain config - playground generates genesis at ~/.playground/devnet/genesis.json +chain = "$HOME/.playground/devnet/genesis.json" + +# Reth data directory - playground stores reth data here when using --use-native-reth +reth_datadir = "$HOME/.playground/devnet/volume-el-data" + +# IPC path for mempool subscription (enabled in modified playground) +el_node_ipc_path = "$HOME/.playground/devnet/volume-el-data/reth.ipc" + +# CL node URL - lighthouse beacon runs on port 3500 +cl_node_url = "http://localhost:3500" + +# Engine API - rbuilder listens here, playground connects via --secondary-el 9551 +jsonrpc_server_port = 9551 +jsonrpc_server_ip = "0.0.0.0" + +# Keys from playground's default configuration +# Relay secret key (used for signing block submissions) +relay_secret_key = "5eae315483f028b5cdd5d1090ff0c7618b18737ea9bf3c35047189db22835c48" + +# Coinbase secret key - this is the default dev account with funds +coinbase_secret_key = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + +# Builder identification +extra_data = "⚑rbuilder-dev" + +# Bidding service configuration +# Options: "true-value" (default) or "reactive" +bidding_service = "reactive" + +# Builder fee to retain when using reactive bidding (in ETH) +builder_fee = "0.01" + +# Start bidding earlier (12 seconds before slot) to catch more blocks in local devnet +slot_delta_to_start_bidding_ms = -12000 + +# Enabled algorithms +live_builders = ["mgp-ordering"] + +# Use playground's mev-boost-relay +enabled_relays = ["playground"] + +# Sparse trie for faster root hash calculation +root_hash_use_sparse_trie = true +root_hash_compare_sparse_trie = false + +# Builder configuration +[[builders]] +name = "mgp-ordering" +algo = "ordering-builder" +discard_txs = true +sorting = "mev-gas-price" +failed_order_retries = 1 +drop_failed_orders = true + +# Playground relay configuration +[[relays]] +name = "playground" +url = "http://localhost:5555" +mode = "full" + +# ═══════════════════════════════════════════════════════════════════════════════ +# BID SCRAPERS - Feed competition bids to ReactiveBuilderFeeBiddingService +# ═══════════════════════════════════════════════════════════════════════════════ +# Uncomment for mainnet/testnet to track competition: +# +# [[relay_bid_scrapers]] +# type = "ultrasound-ws" +# name = "ultrasound-ws-eu" +# ultrasound_url = "ws://relay-builders-eu.ultrasound.money/ws/v1/top_bid" +# relay_name = "ultrasound-money-eu" +# +# [[relay_bid_scrapers]] +# type = "ultrasound-ws" +# name = "ultrasound-ws-us" +# ultrasound_url = "ws://relay-builders-us.ultrasound.money/ws/v1/top_bid" +# relay_name = "ultrasound-money-us"