From 6bb059b6284d6717cd89c990b796c02f382b0e30 Mon Sep 17 00:00:00 2001 From: Felix Leupold Date: Mon, 29 Apr 2024 18:07:23 +0200 Subject: [PATCH] Domain Settlement Encoding (#2661) # Description Main part of #2215. This PR ports the settlement encoding logic which is currently contained in the legacy solver crate's [`SettlemenEncoder`](https://github.com/cowprotocol/services/blob/81831022cc41a05e76547b90e87694dc349a0388/crates/solver/src/settlement/settlement_encoder.rs#L37) to our domain. It is importantly still missing application of slippage tolerance (which is contained in [`SlippageContext`](https://github.com/cowprotocol/services/blob/81831022cc41a05e76547b90e87694dc349a0388/crates/solver/src/liquidity/slippage.rs#L123), porting which would have made this PR even larger. # Changes - [x] Introduce encoding module which exposes a function to take a solution and returns a settlement tx object - [x] Encode clearing prices, trades & interactions the way the settlement contract expects (guided by the boundary implementation) - [x] Implement `revertable` logic on `Solutions` (this also fixes #2640) - [x] Make allowance computation internalisation aware (currently allowances for internalised interactions are added to the execution plan even if we are building an internalised one) - [x] Unify `swap` implementation of the liquidity types to always return `Result` (one returned `Option`) ## How to test - [ ] Change default `encoding::Strategy` from Boundary to Domain and run tests. > [!IMPORTANT] > We still need to implement the slippage computation to make this strategy fully working Fixes #2215 & #2640 --------- Co-authored-by: Martin Beckmann --- crates/driver/src/boundary/settlement.rs | 4 +- .../domain/competition/solution/encoding.rs | 368 ++++++++++++++++++ .../src/domain/competition/solution/mod.rs | 49 ++- .../domain/competition/solution/settlement.rs | 24 +- .../driver/src/domain/liquidity/uniswap/v3.rs | 13 +- crates/driver/src/infra/notify/mod.rs | 1 + 6 files changed, 438 insertions(+), 21 deletions(-) diff --git a/crates/driver/src/boundary/settlement.rs b/crates/driver/src/boundary/settlement.rs index 58cafd9e30..6cf821c554 100644 --- a/crates/driver/src/boundary/settlement.rs +++ b/crates/driver/src/boundary/settlement.rs @@ -109,7 +109,9 @@ impl Settlement { settlement.with_liquidity(&boundary_limit_order, execution)?; } - let approvals = solution.approvals(eth).await?; + let approvals = solution + .approvals(eth, settlement::Internalization::Disable) + .await?; for approval in approvals { settlement .encoder diff --git a/crates/driver/src/domain/competition/solution/encoding.rs b/crates/driver/src/domain/competition/solution/encoding.rs index b5316bc0b1..8fbf5f1d01 100644 --- a/crates/driver/src/domain/competition/solution/encoding.rs +++ b/crates/driver/src/domain/competition/solution/encoding.rs @@ -1,3 +1,19 @@ +use { + super::{error::Math, interaction::Liquidity, settlement, trade::ClearingPrices}, + crate::{ + domain::{ + competition::{ + self, + order::{self, Partial}, + }, + eth::{self, allowance, Ether}, + liquidity::{self, ExactOutput, MaxInput}, + }, + util::Bytes, + }, + allowance::Allowance, +}; + /// The type of strategy used to encode the solution. #[derive(Debug, Copy, Clone)] pub enum Strategy { @@ -6,3 +22,355 @@ pub enum Strategy { /// Use logic from this module for encoding Domain, } + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("invalid interaction: {0:?}")] + InvalidInteractionExecution(competition::solution::interaction::Liquidity), + #[error("invalid clearing price: {0:?}")] + InvalidClearingPrice(eth::TokenAddress), + #[error(transparent)] + Math(#[from] Math), +} + +pub fn tx( + auction_id: competition::auction::Id, + solution: &super::Solution, + contract: &contracts::GPv2Settlement, + approvals: impl Iterator, + internalization: settlement::Internalization, +) -> Result { + let mut tokens = Vec::with_capacity(solution.prices.len() + (solution.trades().len() * 2)); + let mut clearing_prices = + Vec::with_capacity(solution.prices.len() + (solution.trades().len() * 2)); + let mut trades: Vec = Vec::with_capacity(solution.trades().len()); + let mut pre_interactions = Vec::new(); + let mut interactions = + Vec::with_capacity(approvals.size_hint().0 + solution.interactions().len()); + let mut post_interactions = Vec::new(); + + // Encode uniform clearing price vector + for (token, price) in solution.prices.clone() { + tokens.push(token.into()); + clearing_prices.push(price); + } + + // Encode trades with custom clearing prices + for trade in solution.trades() { + let (price, trade) = match trade { + super::Trade::Fulfillment(trade) => { + pre_interactions.extend(trade.order().pre_interactions.clone()); + post_interactions.extend(trade.order().post_interactions.clone()); + + let uniform_prices = ClearingPrices { + sell: solution + .clearing_price(trade.order().sell.token) + .ok_or(Error::InvalidClearingPrice(trade.order().sell.token))?, + buy: solution + .clearing_price(trade.order().buy.token) + .ok_or(Error::InvalidClearingPrice(trade.order().buy.token))?, + }; + let custom_prices = trade.custom_prices(&uniform_prices)?; + ( + Price { + sell_token: trade.order().sell.token.into(), + sell_price: custom_prices.sell, + buy_token: trade.order().buy.token.into(), + buy_price: custom_prices.buy, + }, + Trade { + sell_token_index: (tokens.len() - 2).into(), + buy_token_index: (tokens.len() - 1).into(), + receiver: trade.order().receiver.unwrap_or_default().into(), + sell_amount: trade.order().sell.amount.into(), + buy_amount: trade.order().buy.amount.into(), + valid_to: trade.order().valid_to.into(), + app_data: trade.order().app_data.0 .0.into(), + fee_amount: eth::U256::zero(), + flags: Flags { + side: trade.order().side, + partially_fillable: matches!( + trade.order().partial, + Partial::Yes { .. } + ), + signing_scheme: trade.order().signature.scheme, + sell_token_balance: trade.order().sell_token_balance, + buy_token_balance: trade.order().buy_token_balance, + }, + executed_amount: match trade.order().side { + order::Side::Sell => trade.executed().0 + trade.fee().0, + order::Side::Buy => trade.executed().into(), + }, + signature: trade.order().signature.data.clone(), + }, + ) + } + super::Trade::Jit(trade) => { + ( + Price { + // Jit orders are matched at limit price, so the sell token is worth + // buy.amount and vice versa + sell_token: trade.order().sell.token.0.into(), + sell_price: trade.order().buy.amount.into(), + buy_token: trade.order().buy.token.0.into(), + buy_price: trade.order().sell.amount.into(), + }, + Trade { + sell_token_index: (tokens.len() - 2).into(), + buy_token_index: (tokens.len() - 1).into(), + receiver: trade.order().receiver.into(), + sell_amount: trade.order().sell.amount.into(), + buy_amount: trade.order().buy.amount.into(), + valid_to: trade.order().valid_to.into(), + app_data: trade.order().app_data.0 .0.into(), + fee_amount: eth::U256::zero(), + flags: Flags { + side: trade.order().side, + partially_fillable: trade.order().partially_fillable, + signing_scheme: trade.order().signature.scheme, + sell_token_balance: trade.order().sell_token_balance, + buy_token_balance: trade.order().buy_token_balance, + }, + executed_amount: trade.executed().0, + signature: trade.order().signature.data.clone(), + }, + ) + } + }; + tokens.push(price.sell_token); + tokens.push(price.buy_token); + clearing_prices.push(price.sell_price); + clearing_prices.push(price.buy_price); + + trades.push(trade); + } + + // Encode allowances + for approval in approvals { + interactions.push(approve(&approval.0)) + } + + // Encode interaction + for interaction in solution.interactions() { + if matches!(internalization, settlement::Internalization::Enable) + && interaction.internalize() + { + continue; + } + + interactions.push(match interaction { + competition::solution::Interaction::Custom(interaction) => eth::Interaction { + value: interaction.value, + target: interaction.target.0.into(), + call_data: interaction.call_data.clone(), + }, + competition::solution::Interaction::Liquidity(liquidity) => { + liquidity_interaction(liquidity, contract)? + } + }) + } + + let tx = contract + .settle( + tokens, + clearing_prices, + trades.iter().map(codec::trade).collect(), + [ + pre_interactions.iter().map(codec::interaction).collect(), + interactions.iter().map(codec::interaction).collect(), + post_interactions.iter().map(codec::interaction).collect(), + ], + ) + .into_inner(); + + // Encode the auction id into the calldata + let mut calldata = tx.data.unwrap().0; + calldata.extend(auction_id.to_be_bytes()); + + Ok(eth::Tx { + from: solution.solver().address(), + to: contract.address().into(), + input: calldata.into(), + value: Ether(0.into()), + access_list: Default::default(), + }) +} + +fn liquidity_interaction( + liquidity: &Liquidity, + settlement: &contracts::GPv2Settlement, +) -> Result { + // Todo account for slippage + let input = MaxInput(liquidity.input); + let output = ExactOutput(liquidity.output); + + match liquidity.liquidity.kind.clone() { + liquidity::Kind::UniswapV2(pool) => pool + .swap(&input, &output, &settlement.address().into()) + .ok(), + liquidity::Kind::UniswapV3(pool) => pool + .swap(&input, &output, &settlement.address().into()) + .ok(), + liquidity::Kind::BalancerV2Stable(pool) => pool + .swap(&input, &output, &settlement.address().into()) + .ok(), + liquidity::Kind::BalancerV2Weighted(pool) => pool + .swap(&input, &output, &settlement.address().into()) + .ok(), + liquidity::Kind::Swapr(pool) => pool + .swap(&input, &output, &settlement.address().into()) + .ok(), + liquidity::Kind::ZeroEx(limit_order) => limit_order.to_interaction(&input).ok(), + } + .ok_or(Error::InvalidInteractionExecution(liquidity.clone())) +} + +fn approve(allowance: &Allowance) -> eth::Interaction { + let mut amount = [0u8; 32]; + let selector = hex_literal::hex!("095ea7b3"); + allowance.amount.to_big_endian(&mut amount); + eth::Interaction { + target: allowance.token.0.into(), + value: eth::U256::zero().into(), + // selector (4 bytes) + spender (20 byte address padded to 32 bytes) + amount (32 bytes) + call_data: [ + selector.as_slice(), + [0; 12].as_slice(), + allowance.spender.0.as_bytes(), + &amount, + ] + .concat() + .into(), + } +} + +struct Trade { + sell_token_index: eth::U256, + buy_token_index: eth::U256, + receiver: eth::H160, + sell_amount: eth::U256, + buy_amount: eth::U256, + valid_to: u32, + app_data: Bytes<[u8; 32]>, + fee_amount: eth::U256, + flags: Flags, + executed_amount: eth::U256, + signature: Bytes>, +} + +struct Price { + sell_token: eth::H160, + sell_price: eth::U256, + buy_token: eth::H160, + buy_price: eth::U256, +} + +struct Flags { + side: order::Side, + partially_fillable: bool, + signing_scheme: order::signature::Scheme, + sell_token_balance: order::SellTokenBalance, + buy_token_balance: order::BuyTokenBalance, +} + +mod codec { + use crate::domain::{competition::order, eth}; + + // cf. https://github.com/cowprotocol/contracts/blob/v1.5.0/src/contracts/libraries/GPv2Trade.sol#L16 + type Trade = ( + eth::U256, // sellTokenIndex + eth::U256, // buyTokenIndex + eth::H160, // receiver + eth::U256, // sellAmount + eth::U256, // buyAmount + u32, // validTo + ethcontract::Bytes<[u8; 32]>, // appData + eth::U256, // feeAmount + eth::U256, // flags + eth::U256, // executedAmount + ethcontract::Bytes>, // signature + ); + + pub fn trade(trade: &super::Trade) -> Trade { + ( + trade.sell_token_index, + trade.buy_token_index, + trade.receiver, + trade.sell_amount, + trade.buy_amount, + trade.valid_to, + ethcontract::Bytes(trade.app_data.into()), + trade.fee_amount, + flags(&trade.flags), + trade.executed_amount, + ethcontract::Bytes(trade.signature.0.clone()), + ) + } + + // cf. https://github.com/cowprotocol/contracts/blob/v1.5.0/src/contracts/libraries/GPv2Trade.sol#L58 + fn flags(flags: &super::Flags) -> eth::U256 { + let mut result = 0u8; + // The kind is encoded as 1 bit in position 0. + result |= match flags.side { + order::Side::Sell => 0b0, + order::Side::Buy => 0b1, + }; + // The order fill kind is encoded as 1 bit in position 1. + result |= (flags.partially_fillable as u8) << 1; + // The order sell token balance is encoded as 2 bits in position 2. + result |= match flags.sell_token_balance { + order::SellTokenBalance::Erc20 => 0b00, + order::SellTokenBalance::External => 0b10, + order::SellTokenBalance::Internal => 0b11, + } << 2; + // The order buy token balance is encoded as 1 bit in position 4. + result |= match flags.buy_token_balance { + order::BuyTokenBalance::Erc20 => 0b0, + order::BuyTokenBalance::Internal => 0b1, + } << 4; + // The signing scheme is encoded as a 2 bits in position 5. + result |= match flags.signing_scheme { + order::signature::Scheme::Eip712 => 0b00, + order::signature::Scheme::EthSign => 0b01, + order::signature::Scheme::Eip1271 => 0b10, + order::signature::Scheme::PreSign => 0b11, + } << 5; + result.into() + } + + // cf. https://github.com/cowprotocol/contracts/blob/v1.5.0/src/contracts/libraries/GPv2Interaction.sol#L9 + type Interaction = ( + eth::H160, // target + eth::U256, // value + ethcontract::Bytes>, // signature + ); + + pub fn interaction(interaction: ð::Interaction) -> Interaction { + ( + interaction.target.0, + interaction.value.0, + ethcontract::Bytes(interaction.call_data.0.clone()), + ) + } +} + +#[cfg(test)] +mod test { + use {super::*, hex_literal::hex}; + + #[test] + fn test_approve() { + let allowance = Allowance { + token: eth::H160::from_slice(&hex!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2")).into(), + spender: eth::H160::from_slice(&hex!("000000000022D473030F116dDEE9F6B43aC78BA3")) + .into(), + amount: eth::U256::max_value(), + }; + let interaction = approve(&allowance); + assert_eq!( + interaction.target, + eth::H160::from_slice(&hex!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2")).into(), + ); + assert_eq!(interaction.call_data.0.as_slice(), hex!("095ea7b3000000000000000000000000000000000022d473030f116ddee9f6b43ac78ba3ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff")); + } +} diff --git a/crates/driver/src/domain/competition/solution/mod.rs b/crates/driver/src/domain/competition/solution/mod.rs index 40c42dd165..f1f16c4974 100644 --- a/crates/driver/src/domain/competition/solution/mod.rs +++ b/crates/driver/src/domain/competition/solution/mod.rs @@ -168,15 +168,17 @@ impl Solution { pub async fn approvals( &self, eth: &Ethereum, + internalization: settlement::Internalization, ) -> Result, Error> { let settlement_contract = ð.contracts().settlement(); - let allowances = try_join_all(self.allowances().map(|required| async move { - eth.erc20(required.0.token) - .allowance(settlement_contract.address().into(), required.0.spender) - .await - .map(|existing| (required, existing)) - })) - .await?; + let allowances = + try_join_all(self.allowances(internalization).map(|required| async move { + eth.erc20(required.0.token) + .allowance(settlement_contract.address().into(), required.0.spender) + .await + .map(|existing| (required, existing)) + })) + .await?; let approvals = allowances.into_iter().filter_map(|(required, existing)| { required .approval(&existing) @@ -271,12 +273,20 @@ impl Solution { /// Return the allowances in a normalized form, where there is only one /// allowance per [`eth::allowance::Spender`], and they're ordered /// deterministically. - fn allowances(&self) -> impl Iterator { + fn allowances( + &self, + internalization: settlement::Internalization, + ) -> impl Iterator { let mut normalized = HashMap::new(); - // TODO: we need to carry the "internalize" flag with the allowances, - // since we don't want to include approvals for interactions that are - // meant to be internalized anyway. - let allowances = self.interactions.iter().flat_map(Interaction::allowances); + let allowances = self.interactions.iter().flat_map(|interaction| { + if interaction.internalize() + && matches!(internalization, settlement::Internalization::Enable) + { + vec![] + } else { + interaction.allowances() + } + }); for allowance in allowances { let amount = normalized .entry((allowance.0.token, allowance.0.spender)) @@ -361,6 +371,19 @@ impl Solution { let token = token.wrap(self.weth); self.prices.get(&token).map(ToOwned::to_owned) } + + /// Whether there is a reasonable risk of this solution reverting on chain. + pub fn revertable(&self) -> bool { + self.interactions + .iter() + .any(|interaction| !interaction.internalize()) + || self.user_trades().any(|trade| { + matches!( + trade.order().signature.scheme, + order::signature::Scheme::Eip1271 + ) + }) + } } impl std::fmt::Debug for Solution { @@ -470,6 +493,8 @@ pub mod error { SolverAccountInsufficientBalance(eth::Ether), #[error("attempted to merge settlements generated by different solvers")] DifferentSolvers, + #[error("encoding error: {0:?}")] + Encoding(#[from] encoding::Error), } #[derive(Debug, thiserror::Error)] diff --git a/crates/driver/src/domain/competition/solution/settlement.rs b/crates/driver/src/domain/competition/solution/settlement.rs index 4b3717f643..95af4b2823 100644 --- a/crates/driver/src/domain/competition/solution/settlement.rs +++ b/crates/driver/src/domain/competition/solution/settlement.rs @@ -1,9 +1,9 @@ use { - super::{trade::ClearingPrices, Error, Solution}, + super::{encoding, trade::ClearingPrices, Error, Solution}, crate::{ boundary, domain::{ - competition::{self, auction, encoding, order, solution}, + competition::{self, auction, order, solution}, eth, }, infra::{blockchain::Ethereum, observe, Simulator}, @@ -106,9 +106,23 @@ impl Settlement { may_revert: boundary.revertable(), } } - encoding::Strategy::Domain => { - todo!("domain encoding") - } + encoding::Strategy::Domain => SettlementTx { + internalized: encoding::tx( + auction.id().unwrap(), + &solution, + eth.contracts().settlement(), + solution.approvals(eth, Internalization::Enable).await?, + Internalization::Enable, + )?, + uninternalized: encoding::tx( + auction.id().unwrap(), + &solution, + eth.contracts().settlement(), + solution.approvals(eth, Internalization::Disable).await?, + Internalization::Disable, + )?, + may_revert: solution.revertable(), + }, }; Self::new(auction.id().unwrap(), solution, tx, eth, simulator).await } diff --git a/crates/driver/src/domain/liquidity/uniswap/v3.rs b/crates/driver/src/domain/liquidity/uniswap/v3.rs index efb267c888..7293c1a1e0 100644 --- a/crates/driver/src/domain/liquidity/uniswap/v3.rs +++ b/crates/driver/src/domain/liquidity/uniswap/v3.rs @@ -1,7 +1,10 @@ use { crate::{ boundary, - domain::{eth, liquidity}, + domain::{ + eth, + liquidity::{self, InvalidSwap}, + }, }, derivative::Derivative, std::collections::BTreeMap, @@ -64,11 +67,15 @@ impl Pool { input: &liquidity::MaxInput, output: &liquidity::ExactOutput, receiver: ð::Address, - ) -> Option { + ) -> Result { let tokens_match = (input.0.token == self.tokens.0 && output.0.token == self.tokens.1) || (input.0.token == self.tokens.1 && output.0.token == self.tokens.0); - tokens_match.then_some(boundary::liquidity::uniswap::v3::to_interaction( + if !tokens_match { + return Err(InvalidSwap); + } + + Ok(boundary::liquidity::uniswap::v3::to_interaction( self, input, output, receiver, )) } diff --git a/crates/driver/src/infra/notify/mod.rs b/crates/driver/src/infra/notify/mod.rs index 5709910188..15f3b5f2bb 100644 --- a/crates/driver/src/infra/notify/mod.rs +++ b/crates/driver/src/infra/notify/mod.rs @@ -73,6 +73,7 @@ pub fn encoding_failed( "Settlement gas limit exceeded: used {}, limit {}", used.0, limit.0 )), + solution::Error::Encoding(_) => return, }; solver.notify(auction_id, Some(solution_id.clone()), notification);