Skip to content

Commit 37de01d

Browse files
squadgazzzCopilot
authored andcommitted
Volume fees in the orderbook (cowprotocol#3900)
# Description Adds protocol volume fee support to the orderbook quote API in order to reflect the auction fees after submitting an order using the same quote. The quote response now includes `protocolFeeBps` ~~and `protocolFeeSellAmount`~~ field~~s~~ when volume fees are configured, allowing users to see the fee before placing orders. Volume fees are applied to the surplus token (buy token for sell orders, sell token for buy orders) following the same logic as the driver. The orderbook adjusts quote amounts so that orders can be signed with amounts that will be fillable after the driver applies fees during auction competition. The PriceImprovement and Surplus fees are based on the quotes, so it doesn't make any sense to support them in the quote API. # Changes - Added `--volume-fee` CLI argument to orderbook (e.g., `--volume-fee=0.0002` for 0.02% or 2 basis points) - Accepts decimal values in range `[0, 1)` representing the fee factor - Added optional `protocolFeeBps` (string) ~~and `protocolFeeSellAmount` (U256)~~ field~~s~~ to `OrderQuoteResponse` - The field~~s~~ are only present when `--volume-fee` is configured in the orderbook # Implementation details - Volume fee calculation follows driver logic: https://github.com/cowprotocol/services/blob/31ea719f35072bc6155fdeb990bbc27c9b8833b3/crates/driver/src/domain/competition/solution/fee.rs#L185-L202 - **Sell orders**: Fee calculated on `buy_amount`, reduces the buy amount returned in quote - **Buy orders**: Fee calculated on `sell_amount` + network fee, increases the sell amount returned in quote - ~~Fee amounts are always converted to sell token for the `protocolFeeSellAmount` field~~ - ~~Uses `quote.sell_amount/buy_amount` (final computed amounts after network fees) rather than `quote.data.quoted_sell_amount/quoted_buy_amount` (original exchange rate amounts)~~ ## How to test New unit and e2e tests. ## Further configuration This feature needs to be enabled only on a specific block. This logic will be implemented in a follow-up PR. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent e952516 commit 37de01d

File tree

7 files changed

+399
-8
lines changed

7 files changed

+399
-8
lines changed

crates/e2e/tests/e2e/quoting.rs

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ async fn local_node_quote_timeout() {
3939
run_test(quote_timeout).await;
4040
}
4141

42+
#[tokio::test]
43+
#[ignore]
44+
async fn local_node_volume_fee() {
45+
run_test(volume_fee).await;
46+
}
47+
4248
// Test that quoting works as expected, specifically, that we can quote for a
4349
// token pair and additional gas from ERC-1271 and hooks are included in the
4450
// quoted fee amount.
@@ -403,3 +409,77 @@ async fn quote_timeout(web3: Web3) {
403409
assert!(res.unwrap_err().1.contains("NoLiquidity"));
404410
assert_within_variance(start, MAX_QUOTE_TIME_MS);
405411
}
412+
413+
/// Test that volume fees are correctly applied to quotes.
414+
async fn volume_fee(web3: Web3) {
415+
let mut onchain = OnchainComponents::deploy(web3).await;
416+
417+
let [solver] = onchain.make_solvers(to_wei(10)).await;
418+
let [trader] = onchain.make_accounts(to_wei(10)).await;
419+
let [token] = onchain
420+
.deploy_tokens_with_weth_uni_v2_pools(to_wei(1_000), to_wei(1_000))
421+
.await;
422+
423+
onchain
424+
.contracts()
425+
.weth
426+
.approve(onchain.contracts().allowance.into_alloy(), eth(3))
427+
.from(trader.address().into_alloy())
428+
.send_and_watch()
429+
.await
430+
.unwrap();
431+
onchain
432+
.contracts()
433+
.weth
434+
.deposit()
435+
.from(trader.address().into_alloy())
436+
.value(eth(3))
437+
.send_and_watch()
438+
.await
439+
.unwrap();
440+
441+
tracing::info!("Starting services with volume fee.");
442+
let services = Services::new(&onchain).await;
443+
// Start API with 0.02% (2 bps) volume fee
444+
let args = ExtraServiceArgs {
445+
api: vec!["--volume-fee-factor=0.0002".to_string()],
446+
..Default::default()
447+
};
448+
services.start_protocol_with_args(args, solver).await;
449+
450+
tracing::info!("Testing SELL quote with volume fee");
451+
let sell_request = OrderQuoteRequest {
452+
from: trader.address(),
453+
sell_token: onchain.contracts().weth.address().into_legacy(),
454+
buy_token: token.address().into_legacy(),
455+
side: OrderQuoteSide::Sell {
456+
sell_amount: SellAmount::BeforeFee {
457+
value: NonZeroU256::try_from(to_wei(1)).unwrap(),
458+
},
459+
},
460+
..Default::default()
461+
};
462+
463+
let sell_quote = services.submit_quote(&sell_request).await.unwrap();
464+
465+
// Verify protocol fee fields are present
466+
assert!(sell_quote.protocol_fee_bps.is_some());
467+
assert_eq!(sell_quote.protocol_fee_bps.as_ref().unwrap(), "2");
468+
469+
tracing::info!("Testing BUY quote with volume fee");
470+
let buy_request = OrderQuoteRequest {
471+
from: trader.address(),
472+
sell_token: onchain.contracts().weth.address().into_legacy(),
473+
buy_token: token.address().into_legacy(),
474+
side: OrderQuoteSide::Buy {
475+
buy_amount_after_fee: NonZeroU256::try_from(to_wei(1)).unwrap(),
476+
},
477+
..Default::default()
478+
};
479+
480+
let buy_quote = services.submit_quote(&buy_request).await.unwrap();
481+
482+
// Verify protocol fee fields are present
483+
assert!(buy_quote.protocol_fee_bps.is_some());
484+
assert_eq!(buy_quote.protocol_fee_bps.as_ref().unwrap(), "2");
485+
}

crates/model/src/quote.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,7 @@ pub struct OrderQuote {
324324

325325
pub type QuoteId = i64;
326326

327+
#[serde_as]
327328
#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
328329
#[serde(rename_all = "camelCase")]
329330
pub struct OrderQuoteResponse {
@@ -332,6 +333,9 @@ pub struct OrderQuoteResponse {
332333
pub expiration: DateTime<Utc>,
333334
pub id: Option<QuoteId>,
334335
pub verified: bool,
336+
/// Protocol fee in basis points (e.g., "2" for 0.02%)
337+
#[serde(skip_serializing_if = "Option::is_none")]
338+
pub protocol_fee_bps: Option<String>,
335339
}
336340

337341
#[derive(Debug, Serialize, Deserialize)]

crates/orderbook/openapi.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1682,6 +1682,20 @@ components:
16821682
Whether it was possible to verify that the quoted amounts are
16831683
accurate using a simulation.
16841684
type: boolean
1685+
protocolFeeBps:
1686+
description: >
1687+
Protocol fee in basis points (e.g., "2" for 0.02%). This represents
1688+
the volume-based fee policy. Only present when configured.
1689+
type: string
1690+
example: "2"
1691+
protocolFeeSellAmount:
1692+
description: >
1693+
Protocol fee amount in sell token. For SELL orders, this amount is
1694+
already included in the returned sellAmount. For BUY orders, this
1695+
amount is applied before network fees are added to sellAmount. Only
1696+
present when a volume fee is configured.
1697+
allOf:
1698+
- $ref: "#/components/schemas/TokenAmount"
16851699
required:
16861700
- quote
16871701
- expiration

crates/orderbook/src/api/post_quote.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,7 @@ mod tests {
318318
expiration: Utc.timestamp_millis_opt(0).unwrap(),
319319
id: Some(0),
320320
verified: false,
321+
protocol_fee_bps: Some("2".to_string()),
321322
};
322323
let response = convert_json_response::<OrderQuoteResponse, OrderQuoteErrorWrapper>(Ok(
323324
order_quote_response.clone(),

crates/orderbook/src/arguments.rs

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use {
77
http_client,
88
price_estimation::{self, NativePriceEstimators},
99
},
10-
std::{net::SocketAddr, num::NonZeroUsize, time::Duration},
10+
std::{net::SocketAddr, num::NonZeroUsize, str::FromStr, time::Duration},
1111
};
1212

1313
#[derive(clap::Parser)]
@@ -141,6 +141,49 @@ pub struct Arguments {
141141
/// whether an order is actively being bid on.
142142
#[clap(long, env, default_value = "5")]
143143
pub active_order_competition_threshold: u32,
144+
145+
/// Volume-based protocol fee factor to be applied to quotes.
146+
/// This is a decimal value (e.g., 0.0002 for 0.02% or 2 basis points).
147+
/// The fee is applied to the surplus token (buy token for sell orders,
148+
/// sell token for buy orders).
149+
#[clap(long, env)]
150+
pub volume_fee_factor: Option<FeeFactor>,
151+
}
152+
153+
#[derive(Debug, Clone, Copy, PartialEq)]
154+
pub struct FeeFactor(f64);
155+
156+
impl FeeFactor {
157+
/// Number of basis points that make up 100%.
158+
pub const MAX_BPS: u32 = 10_000;
159+
160+
/// Converts the fee factor to basis points (BPS).
161+
/// For example, 0.0002 -> 2 BPS
162+
pub fn to_bps(&self) -> u64 {
163+
(self.0 * f64::from(Self::MAX_BPS)).round() as u64
164+
}
165+
}
166+
167+
/// TryFrom implementation for the cases we want to enforce the constraint [0,
168+
/// 1)
169+
impl TryFrom<f64> for FeeFactor {
170+
type Error = anyhow::Error;
171+
172+
fn try_from(value: f64) -> Result<Self, Self::Error> {
173+
anyhow::ensure!(
174+
(0.0..1.0).contains(&value),
175+
"Factor must be in the range [0, 1)"
176+
);
177+
Ok(FeeFactor(value))
178+
}
179+
}
180+
181+
impl FromStr for FeeFactor {
182+
type Err = anyhow::Error;
183+
184+
fn from_str(s: &str) -> Result<Self, Self::Err> {
185+
s.parse::<f64>().map(FeeFactor::try_from)?
186+
}
144187
}
145188

146189
impl std::fmt::Display for Arguments {
@@ -172,6 +215,7 @@ impl std::fmt::Display for Arguments {
172215
db_read_url,
173216
max_gas_per_order,
174217
active_order_competition_threshold,
218+
volume_fee_factor: volume_fee,
175219
} = self;
176220

177221
write!(f, "{shared}")?;
@@ -225,6 +269,7 @@ impl std::fmt::Display for Arguments {
225269
f,
226270
"active_order_competition_threshold: {active_order_competition_threshold}"
227271
)?;
272+
writeln!(f, "volume_fee: {volume_fee:?}")?;
228273

229274
Ok(())
230275
}

0 commit comments

Comments
 (0)