diff --git a/crates/autopilot/src/arguments.rs b/crates/autopilot/src/arguments.rs index aee8f2e5f5..6174b51c97 100644 --- a/crates/autopilot/src/arguments.rs +++ b/crates/autopilot/src/arguments.rs @@ -2,6 +2,7 @@ use { crate::{domain::fee::FeeFactor, infra}, alloy::primitives::Address, anyhow::{Context, anyhow, ensure}, + chrono::{DateTime, Utc}, clap::ValueEnum, primitive_types::{H160, U256}, shared::{ @@ -194,13 +195,8 @@ pub struct Arguments { pub solve_deadline: Duration, /// Describes how the protocol fees should be calculated. - #[clap(long, env, use_value_delimiter = true)] - pub fee_policies: Vec, - - /// Maximum partner fee allow. If the partner fee specified is greater than - /// this maximum, the partner fee will be capped - #[clap(long, env, default_value = "0.01")] - pub fee_policy_max_partner_fee: FeeFactor, + #[clap(flatten)] + pub fee_policies_config: FeePoliciesConfig, /// Arguments for uploading information to S3. #[clap(flatten)] @@ -389,8 +385,7 @@ impl std::fmt::Display for Arguments { submission_deadline, shadow, solve_deadline, - fee_policies, - fee_policy_max_partner_fee, + fee_policies_config, order_events_cleanup_interval, order_events_cleanup_threshold, db_write_url, @@ -448,11 +443,7 @@ impl std::fmt::Display for Arguments { writeln!(f, "submission_deadline: {submission_deadline}")?; display_option(f, "shadow", shadow)?; writeln!(f, "solve_deadline: {solve_deadline:?}")?; - writeln!(f, "fee_policies: {fee_policies:?}")?; - writeln!( - f, - "fee_policy_max_partner_fee: {fee_policy_max_partner_fee:?}" - )?; + writeln!(f, "fee_policies_config: {fee_policies_config:?}")?; writeln!( f, "order_events_cleanup_interval: {order_events_cleanup_interval:?}" @@ -585,6 +576,22 @@ impl FromStr for Solver { } } +#[derive(clap::Parser, Debug, Clone)] +pub struct FeePoliciesConfig { + /// Describes how the protocol fees should be calculated. + #[clap(long, env, use_value_delimiter = true)] + pub fee_policies: Vec, + + /// Maximum partner fee allowed. If the partner fee specified is greater + /// than this maximum, the partner fee will be capped + #[clap(long, env, default_value = "0.01")] + pub fee_policy_max_partner_fee: FeeFactor, + + /// Volume fee policies that will become effective at a future timestamp. + #[clap(flatten)] + pub upcoming_fee_policies: UpcomingFeePolicies, +} + /// A fee policy to be used for orders base on it's class. /// Examples: /// - Surplus with a high enough cap for limit orders: surplus:0.5:0.9:limit @@ -604,6 +611,24 @@ pub struct FeePolicy { pub fee_policy_order_class: FeePolicyOrderClass, } +/// Fee policies that will become effective at a future timestamp. +#[derive(clap::Parser, Debug, Clone)] +pub struct UpcomingFeePolicies { + #[clap( + id = "upcoming_fee_policies", + long = "upcoming-fee-policies", + env = "UPCOMING_FEE_POLICIES", + use_value_delimiter = true + )] + pub fee_policies: Vec, + + #[clap( + long = "upcoming-fee-policies-timestamp", + env = "UPCOMING_FEE_POLICIES_TIMESTAMP" + )] + pub effective_from_timestamp: Option>, +} + #[derive(clap::Parser, Debug, Clone)] pub enum FeePolicyKind { /// How much of the order's surplus should be taken as a protocol fee. diff --git a/crates/autopilot/src/domain/fee/mod.rs b/crates/autopilot/src/domain/fee/mod.rs index 262091226f..cf8c3798a9 100644 --- a/crates/autopilot/src/domain/fee/mod.rs +++ b/crates/autopilot/src/domain/fee/mod.rs @@ -14,6 +14,7 @@ use { }, alloy::primitives::{Address, U256}, app_data::Validator, + chrono::{DateTime, Utc}, derive_more::Into, ethrpc::alloy::conversions::{IntoAlloy, IntoLegacy}, primitive_types::H160, @@ -53,25 +54,47 @@ impl From for ProtocolFee { } } +pub struct UpcomingProtocolFees { + fee_policies: Vec, + effective_from_timestamp: DateTime, +} + +impl From for Option { + fn from(value: arguments::UpcomingFeePolicies) -> Self { + value + // both config fields must be non-empty + .effective_from_timestamp + .filter(|_| !value.fee_policies.is_empty()) + .map(|effective_from_timestamp| UpcomingProtocolFees { + fee_policies: value + .fee_policies + .into_iter() + .map(ProtocolFee::from) + .collect::>(), + effective_from_timestamp, + }) + } +} + pub type ProtocolFeeExemptAddresses = HashSet; pub struct ProtocolFees { fee_policies: Vec, max_partner_fee: FeeFactor, + upcoming_fee_policies: Option, } impl ProtocolFees { - pub fn new( - fee_policies: &[arguments::FeePolicy], - fee_policy_max_partner_fee: FeeFactor, - ) -> Self { + pub fn new(config: &arguments::FeePoliciesConfig) -> Self { Self { - fee_policies: fee_policies + fee_policies: config + .fee_policies .iter() .cloned() .map(ProtocolFee::from) .collect(), - max_partner_fee: fee_policy_max_partner_fee, + max_partner_fee: config.fee_policy_max_partner_fee, + upcoming_fee_policies: config.upcoming_fee_policies.clone().into(), } } @@ -230,13 +253,22 @@ impl ProtocolFees { quote: domain::Quote, partner_fees: Vec, ) -> domain::Order { - let protocol_fees = self - .fee_policies + // Use new fee policies if the order creation date is after their effective + // timestamp. + let fee_policies = self + .upcoming_fee_policies + .as_ref() + .filter(|upcoming| order.metadata.creation_date >= upcoming.effective_from_timestamp) + .map(|upcoming| &upcoming.fee_policies) + .unwrap_or(&self.fee_policies); + + let protocol_fees = fee_policies .iter() .filter_map(|fee_policy| Self::protocol_fee_into_policy(&order, "e, fee_policy)) .flat_map(|policy| Self::variant_fee_apply(&order, "e, policy)) .chain(partner_fees) .collect::>(); + boundary::order::to_domain(order, protocol_fees, Some(quote)) } diff --git a/crates/autopilot/src/run.rs b/crates/autopilot/src/run.rs index 2e28e457de..e59bd23a98 100644 --- a/crates/autopilot/src/run.rs +++ b/crates/autopilot/src/run.rs @@ -538,7 +538,7 @@ pub async fn run(args: Arguments, shutdown_controller: ShutdownController) { args.limit_order_price_factor .try_into() .expect("limit order price factor can't be converted to BigDecimal"), - domain::ProtocolFees::new(&args.fee_policies, args.fee_policy_max_partner_fee), + domain::ProtocolFees::new(&args.fee_policies_config), cow_amm_registry.clone(), args.run_loop_native_price_timeout, eth.contracts().settlement().address().into_legacy(), diff --git a/crates/e2e/src/setup/fee.rs b/crates/e2e/src/setup/fee.rs index 1b4e088548..25689cb9e1 100644 --- a/crates/e2e/src/setup/fee.rs +++ b/crates/e2e/src/setup/fee.rs @@ -1,4 +1,16 @@ -pub struct ProtocolFeesConfig(pub Vec); +use chrono::{DateTime, Utc}; + +#[derive(Default)] +pub struct ProtocolFeesConfig { + pub protocol_fees: Vec, + pub upcoming_protocol_fees: Option, +} + +#[derive(Clone)] +pub struct UpcomingProtocolFees { + pub fee_policies: Vec, + pub effective_from_timestamp: DateTime, +} #[derive(Clone)] pub struct ProtocolFee { @@ -57,14 +69,31 @@ impl std::fmt::Display for ProtocolFee { } } -impl std::fmt::Display for ProtocolFeesConfig { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl ProtocolFeesConfig { + pub fn into_args(self) -> Vec { + let mut args = Vec::new(); let fees_str = self - .0 + .protocol_fees .iter() .map(|fee| fee.to_string()) .collect::>() .join(","); - write!(f, "--fee-policies={fees_str}") + args.push(format!("--fee-policies={fees_str}")); + + if let Some(upcoming_protocol_fees) = &self.upcoming_protocol_fees { + let upcoming_fees_str = upcoming_protocol_fees + .fee_policies + .iter() + .map(|fee| fee.to_string()) + .collect::>() + .join(","); + args.push(format!("--upcoming-fee-policies={}", upcoming_fees_str)); + args.push(format!( + "--upcoming-fee-policies-timestamp={}", + upcoming_protocol_fees.effective_from_timestamp.to_rfc3339() + )); + } + + args } } diff --git a/crates/e2e/tests/e2e/limit_orders.rs b/crates/e2e/tests/e2e/limit_orders.rs index f912e680e4..05156a58c6 100644 --- a/crates/e2e/tests/e2e/limit_orders.rs +++ b/crates/e2e/tests/e2e/limit_orders.rs @@ -1066,32 +1066,36 @@ async fn no_liquidity_limit_order(web3: Web3) { .unwrap(); // Setup services - let protocol_fees_config = ProtocolFeesConfig(vec![ - ProtocolFee { - policy: fee::FeePolicyKind::Surplus { - factor: 0.5, - max_volume_factor: 0.01, + let protocol_fee_args = ProtocolFeesConfig { + protocol_fees: vec![ + ProtocolFee { + policy: fee::FeePolicyKind::Surplus { + factor: 0.5, + max_volume_factor: 0.01, + }, + policy_order_class: FeePolicyOrderClass::Limit, }, - policy_order_class: FeePolicyOrderClass::Limit, - }, - ProtocolFee { - policy: fee::FeePolicyKind::PriceImprovement { - factor: 0.5, - max_volume_factor: 0.01, + ProtocolFee { + policy: fee::FeePolicyKind::PriceImprovement { + factor: 0.5, + max_volume_factor: 0.01, + }, + policy_order_class: FeePolicyOrderClass::Market, }, - policy_order_class: FeePolicyOrderClass::Market, - }, - ]) - .to_string(); + ], + ..Default::default() + } + .into_args(); let services = Services::new(&onchain).await; services .start_protocol_with_args( ExtraServiceArgs { - autopilot: vec![ - protocol_fees_config, - format!("--unsupported-tokens={:#x}", unsupported.address()), - ], + autopilot: [ + protocol_fee_args, + vec![format!("--unsupported-tokens={:#x}", unsupported.address())], + ] + .concat(), ..Default::default() }, solver, diff --git a/crates/e2e/tests/e2e/protocol_fee.rs b/crates/e2e/tests/e2e/protocol_fee.rs index f0e657f562..a13c98b3f7 100644 --- a/crates/e2e/tests/e2e/protocol_fee.rs +++ b/crates/e2e/tests/e2e/protocol_fee.rs @@ -34,6 +34,12 @@ async fn local_node_volume_fee_buy_order() { run_test(volume_fee_buy_order_test).await; } +#[tokio::test] +#[ignore] +async fn local_node_volume_fee_buy_upcoming_future_order() { + run_test(volume_fee_buy_order_upcoming_future_test).await; +} + #[tokio::test] #[ignore] async fn local_node_combined_protocol_fees() { @@ -134,10 +140,15 @@ async fn combined_protocol_fees(web3: Web3) { .await .unwrap(); - let autopilot_config = vec![ - ProtocolFeesConfig(vec![limit_surplus_policy, market_price_improvement_policy]).to_string(), - "--fee-policy-max-partner-fee=0.02".to_string(), - ]; + let autopilot_config = [ + ProtocolFeesConfig { + protocol_fees: vec![limit_surplus_policy, market_price_improvement_policy], + ..Default::default() + } + .into_args(), + vec!["--fee-policy-max-partner-fee=0.02".to_string()], + ] + .concat(); let services = Services::new(&onchain).await; services .start_protocol_with_args( @@ -623,16 +634,181 @@ fn sell_order_from_quote(quote: &OrderQuoteResponse) -> OrderCreation { async fn volume_fee_buy_order_test(web3: Web3) { let fee_policy = FeePolicyKind::Volume { factor: 0.1 }; + let outdated_fee_policy = FeePolicyKind::Volume { factor: 0.0002 }; + let protocol_fee = ProtocolFee { + policy: fee_policy, + // The order is in-market, but specifying `Any` order class to make sure it is properly + // applied + policy_order_class: FeePolicyOrderClass::Any, + }; + let outdated_protocol_fee = ProtocolFee { + policy: outdated_fee_policy, + policy_order_class: FeePolicyOrderClass::Any, + }; + // Protocol fee set twice to test that only one policy will apply if the + // autopilot is not configured to support multiple fees + let protocol_fee_args = ProtocolFeesConfig { + protocol_fees: vec![outdated_protocol_fee.clone(), outdated_protocol_fee], + upcoming_protocol_fees: Some(UpcomingProtocolFees { + fee_policies: vec![protocol_fee.clone(), protocol_fee], + // Set the effective time to 10 minutes ago to make sure the new policy + // is applied + effective_from_timestamp: chrono::Utc::now() - chrono::Duration::minutes(10), + }), + } + .into_args(); + + let mut onchain = OnchainComponents::deploy(web3.clone()).await; + + let [solver] = onchain.make_solvers(to_wei(1)).await; + let [trader] = onchain.make_accounts(to_wei(1)).await; + let [token_gno, token_dai] = onchain + .deploy_tokens_with_weth_uni_v2_pools(to_wei(1_000), to_wei(1000)) + .await; + + // Fund trader accounts + token_gno.mint(trader.address(), to_wei(100)).await; + + // Create and fund Uniswap pool + token_gno.mint(solver.address(), to_wei(1000)).await; + token_dai.mint(solver.address(), to_wei(1000)).await; + onchain + .contracts() + .uniswap_v2_factory + .createPair(*token_gno.address(), *token_dai.address()) + .from(solver.address().into_alloy()) + .send_and_watch() + .await + .unwrap(); + + token_gno + .approve(*onchain.contracts().uniswap_v2_router.address(), eth(1000)) + .from(solver.address().into_alloy()) + .send_and_watch() + .await + .unwrap(); + + token_dai + .approve(*onchain.contracts().uniswap_v2_router.address(), eth(1000)) + .from(solver.address().into_alloy()) + .send_and_watch() + .await + .unwrap(); + onchain + .contracts() + .uniswap_v2_router + .addLiquidity( + *token_gno.address(), + *token_dai.address(), + eth(1000), + eth(1000), + ::alloy::primitives::U256::ZERO, + ::alloy::primitives::U256::ZERO, + solver.address().into_alloy(), + ::alloy::primitives::U256::MAX, + ) + .from(solver.address().into_alloy()) + .send_and_watch() + .await + .unwrap(); + + // Approve GPv2 for trading + + token_gno + .approve(onchain.contracts().allowance.into_alloy(), eth(100)) + .from(trader.address().into_alloy()) + .send_and_watch() + .await + .unwrap(); + + // Place Orders + let services = Services::new(&onchain).await; + services + .start_protocol_with_args( + ExtraServiceArgs { + autopilot: protocol_fee_args, + ..Default::default() + }, + solver, + ) + .await; + + let quote = get_quote( + &services, + token_gno.address().into_legacy(), + token_dai.address().into_legacy(), + OrderKind::Buy, + to_wei(5), + model::time::now_in_epoch_seconds() + 300, + ) + .await + .unwrap() + .quote; + + let order = OrderCreation { + sell_token: token_gno.address().into_legacy(), + sell_amount: quote.sell_amount * 3 / 2, + buy_token: token_dai.address().into_legacy(), + buy_amount: to_wei(5), + valid_to: model::time::now_in_epoch_seconds() + 300, + kind: OrderKind::Buy, + ..Default::default() + } + .sign( + EcdsaSigningScheme::Eip712, + &onchain.contracts().domain_separator, + SecretKeyRef::from(&SecretKey::from_slice(trader.private_key()).unwrap()), + ); + let uid = services.create_order(&order).await.unwrap(); + + // Drive solution + tracing::info!("Waiting for trade."); + let metadata_updated = || async { + onchain.mint_block().await; + let order = services.get_order(&uid).await.unwrap(); + !order.metadata.executed_fee.is_zero() + }; + wait_for_condition(TIMEOUT, metadata_updated).await.unwrap(); + + let order = services.get_order(&uid).await.unwrap(); + let fee_in_buy_token = quote.fee_amount * quote.buy_amount / quote.sell_amount; + assert!(order.metadata.executed_fee >= fee_in_buy_token + quote.sell_amount / 10); + + // Check settlement contract balance + let balance_after = token_gno + .balanceOf(*onchain.contracts().gp_settlement.address()) + .call() + .await + .unwrap() + .into_legacy(); + assert_eq!(order.metadata.executed_fee, balance_after); +} + +async fn volume_fee_buy_order_upcoming_future_test(web3: Web3) { + let fee_policy = FeePolicyKind::Volume { factor: 0.1 }; + let future_fee_policy = FeePolicyKind::Volume { factor: 0.0002 }; let protocol_fee = ProtocolFee { policy: fee_policy, // The order is in-market, but specifying `Any` order class to make sure it is properly // applied policy_order_class: FeePolicyOrderClass::Any, }; + let future_protocol_fee = ProtocolFee { + policy: future_fee_policy, + policy_order_class: FeePolicyOrderClass::Any, + }; // Protocol fee set twice to test that only one policy will apply if the // autopilot is not configured to support multiple fees - let protocol_fees_config = - ProtocolFeesConfig(vec![protocol_fee.clone(), protocol_fee]).to_string(); + let protocol_fee_args = ProtocolFeesConfig { + protocol_fees: vec![protocol_fee.clone(), protocol_fee], + upcoming_protocol_fees: Some(UpcomingProtocolFees { + fee_policies: vec![future_protocol_fee.clone(), future_protocol_fee], + // Set the effective time to far in the future to make sure the new policy + // is NOT applied + effective_from_timestamp: chrono::Utc::now() + chrono::Duration::days(1), + }), + } + .into_args(); let mut onchain = OnchainComponents::deploy(web3.clone()).await; @@ -702,7 +878,7 @@ async fn volume_fee_buy_order_test(web3: Web3) { services .start_protocol_with_args( ExtraServiceArgs { - autopilot: vec![protocol_fees_config], + autopilot: protocol_fee_args, ..Default::default() }, solver, diff --git a/crates/e2e/tests/e2e/quoting.rs b/crates/e2e/tests/e2e/quoting.rs index 5f8018f3c6..0711effbfe 100644 --- a/crates/e2e/tests/e2e/quoting.rs +++ b/crates/e2e/tests/e2e/quoting.rs @@ -78,7 +78,16 @@ async fn test(web3: Web3) { tracing::info!("Starting services."); let services = Services::new(&onchain).await; - services.start_protocol(solver).await; + // Start API with 0.02% (2 bps) volume fee + let args = ExtraServiceArgs { + api: vec![ + "--volume-fee-factor=0.0002".to_string(), + // Set a far future effective timestamp to ensure the fee is not applied + "--volume-fee-effective-timestamp=2099-01-01T10:00:00Z".to_string(), + ], + ..Default::default() + }; + services.start_protocol_with_args(args, solver).await; tracing::info!("Quoting order"); let request = OrderQuoteRequest { @@ -442,7 +451,11 @@ async fn volume_fee(web3: Web3) { let services = Services::new(&onchain).await; // Start API with 0.02% (2 bps) volume fee let args = ExtraServiceArgs { - api: vec!["--volume-fee-factor=0.0002".to_string()], + api: vec![ + "--volume-fee-factor=0.0002".to_string(), + // Set a past effective timestamp to ensure the fee is applied + "--volume-fee-effective-timestamp=2000-01-01T10:00:00Z".to_string(), + ], ..Default::default() }; services.start_protocol_with_args(args, solver).await; diff --git a/crates/orderbook/src/arguments.rs b/crates/orderbook/src/arguments.rs index 0cbb8e54a8..e47ba3eb6d 100644 --- a/crates/orderbook/src/arguments.rs +++ b/crates/orderbook/src/arguments.rs @@ -1,5 +1,6 @@ use { alloy::primitives::Address, + chrono::{DateTime, Utc}, reqwest::Url, shared::{ arguments::{display_option, display_secret_option}, @@ -142,12 +143,29 @@ pub struct Arguments { #[clap(long, env, default_value = "5")] pub active_order_competition_threshold: u32, - /// Volume-based protocol fee factor to be applied to quotes. + #[clap(flatten)] + pub volume_fee_config: Option, +} + +/// Volume-based protocol fee factor to be applied to quotes. +#[derive(clap::Parser, Debug, Clone)] +pub struct VolumeFeeConfig { /// This is a decimal value (e.g., 0.0002 for 0.02% or 2 basis points). /// The fee is applied to the surplus token (buy token for sell orders, /// sell token for buy orders). - #[clap(long, env)] - pub volume_fee_factor: Option, + #[clap( + id = "volume_fee_factor", + long = "volume-fee-factor", + env = "VOLUME_FEE_FACTOR" + )] + pub factor: Option, + + /// The timestamp from which the volume fee becomes effective. + #[clap( + long = "volume-fee-effective-timestamp", + env = "VOLUME_FEE_EFFECTIVE_TIMESTAMP" + )] + pub effective_from_timestamp: Option>, } #[derive(Debug, Clone, Copy, PartialEq)] @@ -215,7 +233,7 @@ impl std::fmt::Display for Arguments { db_read_url, max_gas_per_order, active_order_competition_threshold, - volume_fee_factor: volume_fee, + volume_fee_config, } = self; write!(f, "{shared}")?; @@ -269,7 +287,7 @@ impl std::fmt::Display for Arguments { f, "active_order_competition_threshold: {active_order_competition_threshold}" )?; - writeln!(f, "volume_fee: {volume_fee:?}")?; + writeln!(f, "volume_fee_config: {volume_fee_config:?}")?; Ok(()) } diff --git a/crates/orderbook/src/quoter.rs b/crates/orderbook/src/quoter.rs index 5f148a6a9d..489ad669f6 100644 --- a/crates/orderbook/src/quoter.rs +++ b/crates/orderbook/src/quoter.rs @@ -1,5 +1,8 @@ use { - crate::{app_data, arguments::FeeFactor}, + crate::{ + app_data, + arguments::{FeeFactor, VolumeFeeConfig}, + }, chrono::{TimeZone, Utc}, model::{ order::OrderCreationAppData, @@ -39,7 +42,7 @@ pub struct QuoteHandler { optimal_quoter: Arc, fast_quoter: Arc, app_data: Arc, - volume_fee: Option, + volume_fee: Option, } impl QuoteHandler { @@ -47,7 +50,7 @@ impl QuoteHandler { order_validator: Arc, quoter: Arc, app_data: Arc, - volume_fee: Option, + volume_fee: Option, ) -> Self { Self { order_validator, @@ -120,8 +123,9 @@ impl QuoteHandler { } }; - let adjusted_quote = get_adjusted_quote_data("e, self.volume_fee, &request.side) - .map_err(|err| OrderQuoteError::CalculateQuote(err.into()))?; + let adjusted_quote = + get_adjusted_quote_data("e, self.volume_fee.as_ref(), &request.side) + .map_err(|err| OrderQuoteError::CalculateQuote(err.into()))?; let response = OrderQuoteResponse { quote: OrderQuote { sell_token: request.sell_token, @@ -159,10 +163,14 @@ impl QuoteHandler { /// Calculates the protocol fee based on volume fee and adjusts quote amounts. fn get_adjusted_quote_data( quote: &Quote, - volume_fee: Option, + volume_fee: Option<&VolumeFeeConfig>, side: &OrderQuoteSide, ) -> anyhow::Result { - let Some(factor) = volume_fee else { + let Some(factor) = volume_fee + // Only apply volume fee if effective timestamp has come + .filter(|config| config.effective_from_timestamp.is_none_or(|ts| ts <= Utc::now())) + .and_then(|config| config.factor) + else { return Ok(AdjustedQuoteData { sell_amount: quote.sell_amount, buy_amount: quote.buy_amount, @@ -277,6 +285,10 @@ mod tests { #[test] fn test_volume_fee_sell_order() { let volume_fee = FeeFactor::try_from(0.0002).unwrap(); // 0.02% = 2 bps + let volume_fee_config = VolumeFeeConfig { + factor: Some(volume_fee), + effective_from_timestamp: None, + }; // Selling 100 tokens, expecting to buy 100 tokens let quote = create_test_quote(to_wei(100), to_wei(100)); @@ -286,7 +298,7 @@ mod tests { }, }; - let result = get_adjusted_quote_data("e, Some(volume_fee), &side).unwrap(); + let result = get_adjusted_quote_data("e, Some(&volume_fee_config), &side).unwrap(); // For SELL orders: // - sell_amount stays the same @@ -304,6 +316,12 @@ mod tests { #[test] fn test_volume_fee_buy_order() { let volume_fee = FeeFactor::try_from(0.0002).unwrap(); // 0.02% = 2 bps + let past_timestamp = Utc::now() - chrono::Duration::minutes(1); + let volume_fee_config = VolumeFeeConfig { + factor: Some(volume_fee), + // Effective date in the past to ensure fee is applied + effective_from_timestamp: Some(past_timestamp), + }; // Buying 100 tokens, expecting to sell 100 tokens, with no network fee let quote = create_test_quote(to_wei(100), to_wei(100)); @@ -311,7 +329,7 @@ mod tests { buy_amount_after_fee: number::nonzero::U256::try_from(to_wei(100)).unwrap(), }; - let result = get_adjusted_quote_data("e, Some(volume_fee), &side).unwrap(); + let result = get_adjusted_quote_data("e, Some(&volume_fee_config), &side).unwrap(); // For BUY orders with no network fee: // - buy_amount stays the same @@ -329,6 +347,10 @@ mod tests { #[test] fn test_volume_fee_buy_order_with_network_fee() { let volume_fee = FeeFactor::try_from(0.0002).unwrap(); // 0.02% = 2 bps + let volume_fee_config = VolumeFeeConfig { + factor: Some(volume_fee), + effective_from_timestamp: None, + }; // Buying 100 tokens, expecting to sell 100 tokens, with 5 token network fee let mut quote = create_test_quote(to_wei(100), to_wei(100)); @@ -337,7 +359,7 @@ mod tests { buy_amount_after_fee: number::nonzero::U256::try_from(to_wei(100)).unwrap(), }; - let result = get_adjusted_quote_data("e, Some(volume_fee), &side).unwrap(); + let result = get_adjusted_quote_data("e, Some(&volume_fee_config), &side).unwrap(); // For BUY orders with network fee: // - buy_amount stays the same @@ -359,6 +381,10 @@ mod tests { #[test] fn test_volume_fee_different_prices() { let volume_fee = FeeFactor::try_from(0.001).unwrap(); // 0.1% = 10 bps + let volume_fee_config = VolumeFeeConfig { + factor: Some(volume_fee), + effective_from_timestamp: None, + }; // Selling 100 tokens, expecting to buy 200 tokens (2:1 price ratio) let quote = create_test_quote(to_wei(100), to_wei(200)); @@ -368,7 +394,7 @@ mod tests { }, }; - let result = get_adjusted_quote_data("e, Some(volume_fee), &side).unwrap(); + let result = get_adjusted_quote_data("e, Some(&volume_fee_config), &side).unwrap(); assert_eq!(result.protocol_fee_bps, Some("10".to_string())); assert_eq!(result.sell_amount, to_wei(100)); @@ -390,6 +416,10 @@ mod tests { for (factor, expected_bps) in test_cases { let volume_fee = FeeFactor::try_from(factor).unwrap(); + let volume_fee_config = VolumeFeeConfig { + factor: Some(volume_fee), + effective_from_timestamp: None, + }; let quote = create_test_quote(to_wei(100), to_wei(100)); let side = OrderQuoteSide::Sell { @@ -398,9 +428,34 @@ mod tests { }, }; - let result = get_adjusted_quote_data("e, Some(volume_fee), &side).unwrap(); + let result = get_adjusted_quote_data("e, Some(&volume_fee_config), &side).unwrap(); assert_eq!(result.protocol_fee_bps, Some(expected_bps.to_string())); } } + + #[test] + fn test_ignore_volume_fees_before_effective_date() { + let volume_fee = FeeFactor::try_from(0.001).unwrap(); // 0.1% = 10 bps + let future_timestamp = Utc::now() + chrono::Duration::days(1); + let volume_fee_config = VolumeFeeConfig { + factor: Some(volume_fee), + effective_from_timestamp: Some(future_timestamp), + }; + + // Selling 100 tokens, expecting to buy 100 tokens + let quote = create_test_quote(to_wei(100), to_wei(100)); + let side = OrderQuoteSide::Sell { + sell_amount: model::quote::SellAmount::BeforeFee { + value: number::nonzero::U256::try_from(to_wei(100)).unwrap(), + }, + }; + + let result = get_adjusted_quote_data("e, Some(&volume_fee_config), &side).unwrap(); + + // Since the effective date is in the future, no volume fee should be applied + assert_eq!(result.sell_amount, to_wei(100)); + assert_eq!(result.buy_amount, to_wei(100)); + assert_eq!(result.protocol_fee_bps, None); + } } diff --git a/crates/orderbook/src/run.rs b/crates/orderbook/src/run.rs index eb64c83641..cff280fe87 100644 --- a/crates/orderbook/src/run.rs +++ b/crates/orderbook/src/run.rs @@ -461,7 +461,7 @@ pub async fn run(args: Arguments) { order_validator, optimal_quoter, app_data.clone(), - args.volume_fee_factor, + args.volume_fee_config, ) .with_fast_quoter(fast_quoter), );