diff --git a/marketplace-solver/src/state.rs b/marketplace-solver/src/state.rs index 697a273539..c25b253d38 100644 --- a/marketplace-solver/src/state.rs +++ b/marketplace-solver/src/state.rs @@ -1,10 +1,11 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; +use url::Url; use async_trait::async_trait; use committable::Committable; use espresso_types::{ v0_99::{ - BidTx, RollupRegistration, RollupRegistrationBody, RollupUpdate, RollupUpdatebody, + BidTx, NamespaceId, RollupRegistration, RollupRegistrationBody, RollupUpdate, RollupUpdatebody, SolverAuctionResults, }, PubKey, SeqTypes, @@ -259,46 +260,102 @@ impl UpdateSolverState for GlobalState { .collect::>>() } + /// Calculates auction results for permissionless auctions. + /// + /// The auction winner selection process: + /// 1. Groups all bids by namespace + /// 2. For each namespace, selects the bid with the highest amount + /// 3. For namespaces without bids, includes reserve URLs if available + /// + /// # Arguments + /// * `view_number` - The view number for which to calculate auction results + /// + /// # Returns + /// * `SolverResult` - The auction results containing winning bids and reserve URLs async fn calculate_auction_results_permissionless( &self, view_number: ViewNumber, ) -> SolverResult { - // todo (ab): actual logic needs to implemented - // for we, we just return some default results + // Get all bids for this view + let bids = self + .solver + .bid_txs + .get(&view_number) + .map(|bids| bids.values().cloned().collect::>()) + .unwrap_or_default(); + + // Group bids by namespace + let mut namespace_bids: HashMap> = HashMap::new(); + for bid in bids.iter() { + for namespace in &bid.body.namespaces { + namespace_bids + .entry(*namespace) + .or_default() + .push(bid); + } + } + // Get all rollup registrations to check reserve prices let rollups = self.get_all_rollup_registrations().await?; + let reserve_prices: HashMap = rollups + .iter() + .map(|r| (r.body.namespace_id, r.body.reserve_price)) + .collect(); + + // Select winning bids (highest bid for each namespace that meets reserve price) + let mut winning_bids = HashSet::new(); + for (namespace_id, bids) in namespace_bids.iter() { + if let Some(highest_bid) = bids.iter().max_by_key(|bid| bid.body.bid_amount) { + // Only select bid if it meets the reserve price + if let Some(reserve_price) = reserve_prices.get(namespace_id) { + if highest_bid.body.bid_amount >= *reserve_price { + winning_bids.insert((*highest_bid).clone()); + } + } + } + } - let results = SolverAuctionResults::new( + // Get reserve URLs for namespaces without winning bids + let reserve_bids: Vec<(NamespaceId, Url)> = rollups + .into_iter() + .filter_map(|r| { + let namespace_id = r.body.namespace_id; + // Only include reserve URL if there are no winning bids for this namespace + // and the rollup is active + if !namespace_bids.contains_key(&namespace_id) && r.body.active { + r.body.reserve_url.map(|url| (namespace_id, url)) + } else { + None + } + }) + .collect(); + + Ok(SolverAuctionResults::new( view_number, - Vec::new(), - rollups - .into_iter() - .filter_map(|r| Some((r.body.namespace_id, r.body.reserve_url?))) - .collect(), - ); - - Ok(results) + winning_bids.into_iter().collect(), + reserve_bids, + )) } + + /// Calculates auction results for permissioned auctions. + /// + /// This method uses the same logic as permissionless auctions since signature verification + /// is handled at a higher level. The signature parameter is used for authentication only. + /// + /// # Arguments + /// * `view_number` - The view number for which to calculate auction results + /// * `_signature` - The signature key for authentication + /// + /// # Returns + /// * `SolverResult` - The auction results containing winning bids and reserve URLs async fn calculate_auction_results_permissioned( &self, view_number: ViewNumber, - _signauture: ::SignatureKey, + _signature: ::SignatureKey, ) -> SolverResult { - // todo (ab): actual logic needs to implemented - // for we, we just return some default results - - let rollups = self.get_all_rollup_registrations().await?; - - let results = SolverAuctionResults::new( - view_number, - Vec::new(), - rollups - .into_iter() - .filter_map(|r| Some((r.body.namespace_id, r.body.reserve_url?))) - .collect(), - ); - - Ok(results) + // For permissioned auctions, we use the same logic as permissionless + // since the signature verification is handled at a higher level + self.calculate_auction_results_permissionless(view_number).await } } diff --git a/marketplace-solver/src/testing.rs b/marketplace-solver/src/testing.rs index 70b392fa18..c31cb4489e 100755 --- a/marketplace-solver/src/testing.rs +++ b/marketplace-solver/src/testing.rs @@ -127,16 +127,16 @@ mod test { use committable::Committable; use espresso_types::{ v0_99::{ - BidTx, RollupRegistration, RollupRegistrationBody, RollupUpdate, RollupUpdatebody, + BidTx, BidTxBody, RollupRegistration, RollupRegistrationBody, RollupUpdate, RollupUpdatebody, }, FeeAccount, MarketplaceVersion, SeqTypes, Update::{Set, Skip}, }; use hotshot::types::{BLSPubKey, SignatureKey}; - use hotshot_types::traits::node_implementation::NodeType; + use hotshot_types::{data::ViewNumber, traits::node_implementation::NodeType}; use tide_disco::Url; - use crate::{testing::MockSolver, SolverError}; + use crate::{testing::MockSolver, SolverError, state::UpdateSolverState}; async fn register_rollup_helper( namespace_id: u64, @@ -588,4 +588,144 @@ mod test { assert_eq!(result[0], reg_ns_1); assert_eq!(result[1], reg_ns_2); } + + #[tokio::test(flavor = "multi_thread")] + async fn test_calculate_auction_results_no_bids() { + let mock_solver = MockSolver::init().await; + let state = mock_solver.state(); + let state = state.read().await; + let view_number = ViewNumber::new(1); + + let results = state + .calculate_auction_results_permissionless(view_number) + .await + .unwrap(); + + assert!(results.winning_bids.is_empty()); + assert!(results.reserve_bids.is_empty()); + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_calculate_auction_results_with_bids() { + let mock_solver = MockSolver::init().await; + let state = mock_solver.state(); + let mut state = state.write().await; + let view_number = ViewNumber::new(1); + + // Create test namespace and bid + let namespace_id = 1u64.into(); + let bid_amount = 1000; + + let bid_tx = BidTx { + body: BidTxBody { + view: view_number, + namespaces: vec![namespace_id], + bid_amount, + ..Default::default() + }, + signature: Default::default(), + }; + + // Submit bid + state.submit_bid_tx(bid_tx.clone()).await.unwrap(); + + // Register rollup with reserve price + let (registration, ..) = register_rollup_helper( + 1, // namespace_id + Some("http://example.com"), + 500, // reserve_price (lower than bid amount) + true, // active + "test rollup" + ).await; + + state.register_rollup(registration).await.unwrap(); + + let results = state + .calculate_auction_results_permissionless(view_number) + .await + .unwrap(); + + assert_eq!(results.winning_bids.len(), 1); + assert_eq!(results.winning_bids[0], bid_tx); + assert!(results.reserve_bids.is_empty()); + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_calculate_auction_results_bid_below_reserve() { + let mock_solver = MockSolver::init().await; + let state = mock_solver.state(); + let mut state = state.write().await; + let view_number = ViewNumber::new(1); + + // Create test namespace and bid + let namespace_id = 1u64.into(); + let bid_amount = 100; + + let bid_tx = BidTx { + body: BidTxBody { + view: view_number, + namespaces: vec![namespace_id], + bid_amount, + ..Default::default() + }, + signature: Default::default(), + }; + + // Submit bid + state.submit_bid_tx(bid_tx).await.unwrap(); + + // Register rollup with higher reserve price + let (registration, ..) = register_rollup_helper( + 1, // namespace_id + Some("http://example.com"), + 500, // reserve_price (higher than bid amount) + true, // active + "test rollup" + ).await; + + state.register_rollup(registration).await.unwrap(); + + let results = state + .calculate_auction_results_permissionless(view_number) + .await + .unwrap(); + + // Bid should not win as it's below reserve price + assert!(results.winning_bids.is_empty()); + // Reserve URL should be included + assert_eq!(results.reserve_bids.len(), 1); + assert_eq!( + results.reserve_bids[0], + (namespace_id, Url::parse("http://example.com").unwrap()) + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_calculate_auction_results_inactive_rollup() { + let mock_solver = MockSolver::init().await; + let state = mock_solver.state(); + let mut state = state.write().await; + let view_number = ViewNumber::new(1); + + // Register inactive rollup + let (registration, ..) = register_rollup_helper( + 1, // namespace_id + Some("http://example.com"), + 500, // reserve_price + false, // inactive + "test rollup" + ).await; + + state.register_rollup(registration).await.unwrap(); + + let results = state + .calculate_auction_results_permissionless(view_number) + .await + .unwrap(); + + // No winning bids + assert!(results.winning_bids.is_empty()); + // No reserve URLs for inactive rollup + assert!(results.reserve_bids.is_empty()); + } }