Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Multiple winners in autopilot #2996

Merged
merged 16 commits into from
Oct 4, 2024
Merged
7 changes: 7 additions & 0 deletions crates/autopilot/src/arguments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,11 @@ pub struct Arguments {
/// If the value is 0, the native prices are fetched from the cache
#[clap(long, env, default_value = "0s", value_parser = humantime::parse_duration)]
pub run_loop_native_price_timeout: Duration,

#[clap(long, env, default_value = "1")]
/// The maximum number of winners per auction. Each winner will be allowed
/// to settle their winning orders at the same time.
pub max_winners_per_auction: usize,
}

impl std::fmt::Display for Arguments {
Expand Down Expand Up @@ -277,6 +282,7 @@ impl std::fmt::Display for Arguments {
run_loop_mode,
max_run_loop_delay,
run_loop_native_price_timeout,
max_winners_per_auction,
} = self;

write!(f, "{}", shared)?;
Expand Down Expand Up @@ -356,6 +362,7 @@ impl std::fmt::Display for Arguments {
"run_loop_native_price_timeout: {:?}",
run_loop_native_price_timeout
)?;
writeln!(f, "max_winners_per_auction: {:?}", max_winners_per_auction)?;
Ok(())
}
}
Expand Down
43 changes: 22 additions & 21 deletions crates/autopilot/src/infra/solvers/dto/solve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,27 +98,7 @@ impl Solution {
domain::competition::Score::new(self.score.into())?,
self.orders
.into_iter()
.map(|(o, amounts)| {
(
o.into(),
domain::competition::TradedOrder {
sell: eth::Asset {
token: amounts.sell_token.into(),
amount: amounts.limit_sell.into(),
},
buy: eth::Asset {
token: amounts.buy_token.into(),
amount: amounts.limit_buy.into(),
},
side: match amounts.side {
Side::Buy => domain::auction::order::Side::Buy,
Side::Sell => domain::auction::order::Side::Sell,
},
executed_sell: amounts.executed_sell.into(),
executed_buy: amounts.executed_buy.into(),
},
)
})
.map(|(o, amounts)| (o.into(), amounts.into_domain()))
.collect(),
self.clearing_prices
.into_iter()
Expand Down Expand Up @@ -155,6 +135,27 @@ pub struct TradedOrder {
executed_buy: U256,
}

impl TradedOrder {
pub fn into_domain(self) -> domain::competition::TradedOrder {
domain::competition::TradedOrder {
sell: eth::Asset {
token: self.sell_token.into(),
amount: self.limit_sell.into(),
},
buy: eth::Asset {
token: self.buy_token.into(),
amount: self.limit_buy.into(),
},
side: match self.side {
Side::Buy => domain::auction::order::Side::Buy,
Side::Sell => domain::auction::order::Side::Sell,
},
executed_sell: self.executed_sell.into(),
executed_buy: self.executed_buy.into(),
}
}
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved into a separate function because now it's called in two places (runloop and shadow)

#[serde_as]
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
Expand Down
2 changes: 2 additions & 0 deletions crates/autopilot/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,7 @@ pub async fn run(args: Arguments) {
solve_deadline: args.solve_deadline,
synchronization: args.run_loop_mode,
max_run_loop_delay: args.max_run_loop_delay,
max_winners_per_auction: args.max_winners_per_auction,
};

let run = RunLoop::new(
Expand Down Expand Up @@ -622,6 +623,7 @@ async fn shadow_mode(args: Arguments) -> ! {
liveness.clone(),
args.run_loop_mode,
current_block,
args.max_winners_per_auction,
);
shadow.run_forever().await;

Expand Down
115 changes: 79 additions & 36 deletions crates/autopilot/src/run_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ use {
rand::seq::SliceRandom,
shared::token_list::AutoUpdatingTokenList,
std::{
collections::{HashMap, HashSet},
collections::{HashMap, HashSet, VecDeque},
sync::Arc,
time::{Duration, Instant},
},
Expand All @@ -52,6 +52,7 @@ pub struct Config {
/// allowed to start before it has to re-synchronize to the blockchain
/// by waiting for the next block to appear.
pub max_run_loop_delay: Duration,
pub max_winners_per_auction: usize,
}

pub struct RunLoop {
Expand Down Expand Up @@ -83,6 +84,13 @@ impl RunLoop {
liveness: Arc<Liveness>,
maintenance: Arc<Maintenance>,
) -> Self {
// Added to make sure no more than one winner is activated by accident
// Supposed to be removed after the implementation of "multiple winners per
// auction" is done
assert_eq!(
config.max_winners_per_auction, 1,
"only one winner is supported"
);
Self {
config,
eth,
Expand Down Expand Up @@ -232,35 +240,36 @@ impl RunLoop {
let auction = self.remove_in_flight_orders(auction).await;

let solutions = self.competition(&auction).await;
if solutions.is_empty() {
tracing::info!("no solutions for auction");
let winners = self.select_winners(&solutions);
m-lord-renkse marked this conversation as resolved.
Show resolved Hide resolved
if winners.is_empty() {
tracing::info!("no winners for auction");
return;
}

let competition_simulation_block = self.eth.current_block().borrow().number;
let block_deadline = competition_simulation_block + self.config.submission_deadline;

// Post-processing should not be executed asynchronously since it includes steps
// of storing all the competition/auction-related data to the DB.
if let Err(err) = self
.post_processing(
&auction,
competition_simulation_block,
// TODO: Support multiple winners
// https://github.com/cowprotocol/services/issues/3021
&winners.first().expect("must exist").solution,
&solutions,
block_deadline,
)
.await
{
tracing::error!(?err, "failed to post-process competition");
return;
}

// TODO: Keep going with other solutions until some deadline.
if let Some(Participant { driver, solution }) = solutions.last() {
for Participant { driver, solution } in winners {
tracing::info!(driver = %driver.name, solution = %solution.id(), "winner");
m-lord-renkse marked this conversation as resolved.
Show resolved Hide resolved

let block_deadline = competition_simulation_block + self.config.submission_deadline;

// Post-processing should not be executed asynchronously since it includes steps
// of storing all the competition/auction-related data to the DB.
if let Err(err) = self
.post_processing(
&auction,
competition_simulation_block,
solution,
&solutions,
block_deadline,
)
.await
{
tracing::error!(?err, "failed to post-process competition");
return;
}

self.start_settlement_execution(
auction.id,
single_run_start,
Expand Down Expand Up @@ -368,15 +377,15 @@ impl RunLoop {
auction: &domain::Auction,
competition_simulation_block: u64,
winning_solution: &competition::Solution,
solutions: &[Participant],
solutions: &VecDeque<Participant>,
block_deadline: u64,
) -> Result<()> {
let start = Instant::now();
let winner = winning_solution.solver().into();
let winning_score = winning_solution.score().get().0;
let reference_score = solutions
.iter()
.nth_back(1)
// todo multiple winners per auction
.get(1)
sunce86 marked this conversation as resolved.
Show resolved Hide resolved
.map(|participant| participant.solution.score().get().0)
.unwrap_or_default();
let participants = solutions
Expand Down Expand Up @@ -420,6 +429,8 @@ impl RunLoop {
},
solutions: solutions
.iter()
// reverse as solver competition table is sorted from worst to best, so we need to keep the ordering for backwards compatibility
.rev()
.enumerate()
.map(|(index, participant)| SolverSettlement {
solver: participant.driver.name.clone(),
Expand Down Expand Up @@ -483,8 +494,8 @@ impl RunLoop {
}

/// Runs the solver competition, making all configured drivers participate.
/// Returns all fair solutions sorted by their score (worst to best).
async fn competition(&self, auction: &domain::Auction) -> Vec<Participant> {
/// Returns all fair solutions sorted by their score (best to worst).
async fn competition(&self, auction: &domain::Auction) -> VecDeque<Participant> {
let request = solve::Request::new(
auction,
&self.market_makable_token_list.all(),
Expand All @@ -508,11 +519,14 @@ impl RunLoop {

// Shuffle so that sorting randomly splits ties.
solutions.shuffle(&mut rand::thread_rng());
solutions.sort_unstable_by_key(|participant| participant.solution.score().get().0);
solutions.sort_unstable_by_key(|participant| {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like shuffling and sorting by score would also fit into the shared function to pick a winner.
That way the entire core decision making is shared correctly across shadow and regular autopilot.

std::cmp::Reverse(participant.solution.score().get().0)
});
sunce86 marked this conversation as resolved.
Show resolved Hide resolved

// Make sure the winning solution is fair.
while !Self::is_solution_fair(solutions.last(), &solutions, auction) {
let unfair_solution = solutions.pop().expect("must exist");
let mut solutions = solutions.into_iter().collect::<VecDeque<_>>();
while !Self::is_solution_fair(solutions.front(), &solutions, auction) {
let unfair_solution = solutions.pop_front().expect("must exist");
tracing::warn!(
invalidated = unfair_solution.driver.name,
"fairness check invalidated of solution"
Expand All @@ -523,10 +537,39 @@ impl RunLoop {
solutions
}

/// Chooses the winners from the given participants.
///
/// Participants are already sorted by their score (best to worst).
///
/// Winners are selected one by one, starting from the best solution,
/// until `max_winners_per_auction` is hit. The solution can become winner
/// if it swaps tokens that are not yet swapped by any other already
/// selected winner.
fn select_winners<'a>(&self, participants: &'a VecDeque<Participant>) -> Vec<&'a Participant> {
sunce86 marked this conversation as resolved.
Show resolved Hide resolved
let mut winners = Vec::new();
let mut already_swapped_tokens = HashSet::new();
for participant in participants.iter() {
let swapped_tokens = participant
sunce86 marked this conversation as resolved.
Show resolved Hide resolved
.solution
.orders()
.iter()
.map(|(_, order)| (order.sell.token, order.buy.token))
.collect::<HashSet<_>>();
sunce86 marked this conversation as resolved.
Show resolved Hide resolved
if swapped_tokens.is_disjoint(&already_swapped_tokens) {
sunce86 marked this conversation as resolved.
Show resolved Hide resolved
winners.push(participant);
already_swapped_tokens.extend(swapped_tokens);
if winners.len() >= self.config.max_winners_per_auction {
break;
}
}
}
winners
}

/// Records metrics, order events and logs for the given solutions.
/// Expects the winning solution to be the last in the list.
fn report_on_solutions(&self, solutions: &[Participant], auction: &domain::Auction) {
let Some(winner) = solutions.last() else {
/// Expects the winning solution to be the first in the list.
fn report_on_solutions(&self, solutions: &VecDeque<Participant>, auction: &domain::Auction) {
let Some(winner) = solutions.front() else {
// no solutions means nothing to report
return;
};
Expand All @@ -545,7 +588,7 @@ impl RunLoop {
.flat_map(|solution| solution.solution.order_ids().copied())
.collect();
let winning_orders: HashSet<_> = solutions
.last()
.front()
.into_iter()
.flat_map(|solution| solution.solution.order_ids().copied())
.collect();
Expand All @@ -571,7 +614,7 @@ impl RunLoop {
/// Returns true if winning solution is fair or winner is None
fn is_solution_fair(
winner: Option<&Participant>,
remaining: &Vec<Participant>,
remaining: &VecDeque<Participant>,
auction: &domain::Auction,
) -> bool {
let Some(winner) = winner else { return true };
Expand Down
Loading
Loading