Skip to content

Commit 294e25a

Browse files
committed
Volume fees in the orderbook
1 parent 1784fe7 commit 294e25a

File tree

9 files changed

+522
-8
lines changed

9 files changed

+522
-8
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/e2e/tests/e2e/quoting.rs

Lines changed: 109 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,106 @@ 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+
tracing::info!("Setting up chain state.");
416+
let mut onchain = OnchainComponents::deploy(web3).await;
417+
418+
let [solver] = onchain.make_solvers(to_wei(10)).await;
419+
let [trader] = onchain.make_accounts(to_wei(10)).await;
420+
let [token] = onchain
421+
.deploy_tokens_with_weth_uni_v2_pools(to_wei(1_000), to_wei(1_000))
422+
.await;
423+
424+
onchain
425+
.contracts()
426+
.weth
427+
.approve(onchain.contracts().allowance.into_alloy(), eth(3))
428+
.from(trader.address().into_alloy())
429+
.send_and_watch()
430+
.await
431+
.unwrap();
432+
onchain
433+
.contracts()
434+
.weth
435+
.deposit()
436+
.from(trader.address().into_alloy())
437+
.value(eth(3))
438+
.send_and_watch()
439+
.await
440+
.unwrap();
441+
442+
tracing::info!("Starting services with volume fee.");
443+
let services = Services::new(&onchain).await;
444+
// Start API with 0.02% (2 bps) volume fee
445+
let args = ExtraServiceArgs {
446+
api: vec!["--volume-fee=0.0002".to_string()],
447+
..Default::default()
448+
};
449+
services.start_protocol_with_args(args, solver).await;
450+
451+
tracing::info!("Testing SELL quote with volume fee");
452+
let sell_request = OrderQuoteRequest {
453+
from: trader.address(),
454+
sell_token: onchain.contracts().weth.address().into_legacy(),
455+
buy_token: token.address().into_legacy(),
456+
side: OrderQuoteSide::Sell {
457+
sell_amount: SellAmount::BeforeFee {
458+
value: NonZeroU256::try_from(to_wei(1)).unwrap(),
459+
},
460+
},
461+
..Default::default()
462+
};
463+
464+
let sell_quote = services.submit_quote(&sell_request).await.unwrap();
465+
466+
// Verify protocol fee fields are present
467+
assert!(sell_quote.protocol_fee_bps.is_some());
468+
assert_eq!(sell_quote.protocol_fee_bps.as_ref().unwrap(), "2");
469+
assert!(sell_quote.protocol_fee_sell_amount.is_some());
470+
471+
// For SELL orders: buy_amount should be reduced by the protocol fee
472+
let protocol_fee_sell_amount = sell_quote
473+
.protocol_fee_sell_amount
474+
.unwrap()
475+
.to_string()
476+
.parse::<u128>()
477+
.unwrap();
478+
assert!(
479+
protocol_fee_sell_amount > 0,
480+
"Protocol fee should be non-zero"
481+
);
482+
483+
tracing::info!("Testing BUY quote with volume fee");
484+
let buy_request = OrderQuoteRequest {
485+
from: trader.address(),
486+
sell_token: onchain.contracts().weth.address().into_legacy(),
487+
buy_token: token.address().into_legacy(),
488+
side: OrderQuoteSide::Buy {
489+
buy_amount_after_fee: NonZeroU256::try_from(to_wei(1)).unwrap(),
490+
},
491+
..Default::default()
492+
};
493+
494+
let buy_quote = services.submit_quote(&buy_request).await.unwrap();
495+
496+
// Verify protocol fee fields are present
497+
assert!(buy_quote.protocol_fee_bps.is_some());
498+
assert_eq!(buy_quote.protocol_fee_bps.as_ref().unwrap(), "2");
499+
assert!(buy_quote.protocol_fee_sell_amount.is_some());
500+
501+
// For BUY orders: sell_amount should be increased by the protocol fee
502+
let protocol_fee_sell_amount_buy = buy_quote
503+
.protocol_fee_sell_amount
504+
.unwrap()
505+
.to_string()
506+
.parse::<u128>()
507+
.unwrap();
508+
assert!(
509+
protocol_fee_sell_amount_buy > 0,
510+
"Protocol fee should be non-zero for buy orders"
511+
);
512+
513+
tracing::info!("Volume fee test passed");
514+
}

crates/model/src/quote.rs

Lines changed: 9 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,14 @@ 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>,
339+
/// Protocol fee amount in sell token, already included in sellAmount for
340+
/// SELL orders, or applied before network fees for BUY orders
341+
#[serde(skip_serializing_if = "Option::is_none")]
342+
#[serde_as(as = "Option<HexOrDecimalU256>")]
343+
pub protocol_fee_sell_amount: Option<U256>,
335344
}
336345

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

crates/orderbook/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ chrono = { workspace = true, features = ["clock"] }
2727
clap = { workspace = true }
2828
contracts = { workspace = true }
2929
database = { workspace = true }
30+
derive_more = { workspace = true }
3031
ethcontract = { workspace = true }
3132
ethrpc = { workspace = true }
3233
futures = { workspace = true }
@@ -59,6 +60,7 @@ warp = { workspace = true }
5960

6061
[dev-dependencies]
6162
mockall = { workspace = true }
63+
shared = { workspace = true, features = ["test-util"] }
6264
tokio = { workspace = true, features = ["test-util"] }
6365

6466
[build-dependencies]

crates/orderbook/openapi.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1682,6 +1682,19 @@ 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+
$ref: "#/components/schemas/TokenAmount"
16851698
required:
16861699
- quote
16871700
- expiration

crates/orderbook/src/api/post_quote.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ mod tests {
105105
},
106106
},
107107
number::nonzero::U256 as NonZeroU256,
108+
primitive_types::U256,
108109
reqwest::StatusCode,
109110
serde_json::json,
110111
shared::order_quoting::CalculateQuoteError,
@@ -318,6 +319,8 @@ mod tests {
318319
expiration: Utc.timestamp_millis_opt(0).unwrap(),
319320
id: Some(0),
320321
verified: false,
322+
protocol_fee_bps: Some("2".to_string()),
323+
protocol_fee_sell_amount: Some(U256::from(20)),
321324
};
322325
let response = convert_json_response::<OrderQuoteResponse, OrderQuoteErrorWrapper>(Ok(
323326
order_quote_response.clone(),

crates/orderbook/src/arguments.rs

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
use {
22
alloy::primitives::Address,
3+
derive_more::Into,
34
reqwest::Url,
45
shared::{
56
arguments::{display_option, display_secret_option},
67
bad_token::token_owner_finder,
78
http_client,
89
price_estimation::{self, NativePriceEstimators},
910
},
10-
std::{net::SocketAddr, num::NonZeroUsize, time::Duration},
11+
std::{net::SocketAddr, num::NonZeroUsize, str::FromStr, time::Duration},
1112
};
1213

1314
#[derive(clap::Parser)]
@@ -141,6 +142,48 @@ pub struct Arguments {
141142
/// whether an order is actively being bid on.
142143
#[clap(long, env, default_value = "5")]
143144
pub active_order_competition_threshold: u32,
145+
146+
/// Volume-based protocol fee factor to be applied to quotes.
147+
/// This is a decimal value (e.g., 0.0002 for 0.02% or 2 basis points).
148+
/// The fee is applied to the surplus token (buy token for sell orders,
149+
/// sell token for buy orders).
150+
#[clap(long, env)]
151+
pub volume_fee: Option<FeeFactor>,
152+
}
153+
154+
#[derive(Debug, Clone, Copy, PartialEq, Into)]
155+
pub struct FeeFactor(f64);
156+
157+
impl FeeFactor {
158+
/// Number of basis points that make up 100%.
159+
const MAX_BPS: u32 = 10_000;
160+
161+
/// Converts the fee factor to basis points (BPS).
162+
/// For example, 0.0002 -> 2 BPS
163+
pub fn to_bps(&self) -> u64 {
164+
(self.0 * f64::from(Self::MAX_BPS)).round() as u64
165+
}
166+
}
167+
168+
/// TryFrom implementation for the cases we want to enforce the constrain [0, 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,
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)