Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 38 additions & 5 deletions crates/autopilot/src/fast_path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,12 @@ use {
futures::{StreamExt, channel::mpsc},
model::order::OrderKind,
number::conversions::u256_to_big_decimal,
std::{sync::Arc, time::Instant},
price_estimation::native::to_normalized_price,
std::{
collections::{BTreeMap, HashMap},
sync::Arc,
time::Instant,
},
tracing::{Instrument, instrument},
winner_selection as winsel,
};
Expand All @@ -66,6 +71,10 @@ pub struct FastPathHandler {
/// from one block count and can't drift apart. `None` disables the fast
/// path: orders drop straight into the next regular auction.
exclusivity_period_blocks: Option<u64>,
/// CIP-87 penalty cap calculator, present only when fast-path penalties are
/// enabled (`fast_path_penalty_cap_enabled`). `None` leaves fast-path
/// orders without a penalty cap.
penalty_cap_calculator: Option<Arc<domain::penalty_cap::PenaltyCapCalculator>>,
}

impl FastPathHandler {
Expand All @@ -78,6 +87,7 @@ impl FastPathHandler {
surplus_capturing_jit_order_owners: Arc<Vec<Address>>,
settle_coordinator: Arc<SettleCall>,
exclusivity_period_blocks: Option<u64>,
penalty_cap_calculator: Option<Arc<domain::penalty_cap::PenaltyCapCalculator>>,
) -> Arc<Self> {
Arc::new(Self {
eth,
Expand All @@ -87,6 +97,7 @@ impl FastPathHandler {
surplus_capturing_jit_order_owners,
settle_coordinator,
exclusivity_period_blocks,
penalty_cap_calculator,
})
}

Expand Down Expand Up @@ -442,6 +453,8 @@ impl FastPathHandler {

let reference_score = compute_reference_score(auction_id, &solution_rows)?;

let penalty_cap_native = self.penalty_cap_native(&order, &staged.data.native_prices);

self.persistence
.finalize_fast_path(FastPathPromotion {
quote_id: staged.quote_id,
Expand All @@ -454,10 +467,7 @@ impl FastPathHandler {
solutions: solution_rows,
fee_policies: volume_fee_policies.clone(),
reference_score,
// TODO: populate penalty caps correctly. For a brief period after the
// launch there will be no penalties but we already need to store a
// 0 value for the accounting pipeline to work.
penalty_cap_native: 0.into(),
penalty_cap_native,
})
.await
.map_err(PreflightError::PersistFailed)?;
Expand All @@ -470,6 +480,29 @@ impl FastPathHandler {
})
}

/// The CIP-87 penalty cap for a fast-path order: the same cap a regular
/// auction would assign, or 0 when the calculator is disabled.
fn penalty_cap_native(
&self,
order: &model::order::Order,
native_prices: &HashMap<Address, U256>,
) -> BigDecimal {
let Some(calculator) = self.penalty_cap_calculator.as_ref() else {
return 0.into();
};
let mut prices: BTreeMap<Address, U256> = native_prices
.iter()
.map(|(token, price)| (*token, *price))
.collect();
// Buy-ETH orders key the native price under the ETH marker, but the
// calculator looks it up under WETH. Insert WETH at 1 so the lookup
// hits.
prices
.entry(*self.eth.contracts().weth().address())
.or_insert_with(|| to_normalized_price(1.0).expect("1.0 is a valid native price"));
u256_to_big_decimal(&calculator.calculate(order, &prices).0)
}

/// Computes timestamp and number of the last block the order may be
/// executed in.
fn compute_submission_deadline(&self, exclusivity_period_blocks: u64) -> SubmissionDeadline {
Expand Down
14 changes: 11 additions & 3 deletions crates/autopilot/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -491,15 +491,15 @@ pub async fn run(config: Configuration, shutdown_controller: ShutdownController)
.spawn(db_write.pool.clone());

let penalty_cap_calculator = match &config.penalty_cap {
Some(penalty_cap_config) => Some(
Some(penalty_cap_config) => Some(Arc::new(
build_penalty_cap_calculator(
penalty_cap_config,
*eth.contracts().weth().address(),
token_info_fetcher.as_ref(),
&competition_native_price_updater,
)
.await,
),
)),
None => None,
};

Expand All @@ -526,7 +526,7 @@ pub async fn run(config: Configuration, shutdown_controller: ShutdownController)
competition_native_price_updater.clone(),
*eth.contracts().weth().address(),
protocol_fees.clone(),
penalty_cap_calculator,
penalty_cap_calculator.clone(),
surplus_capturing_jit_order_owners.clone(),
config.native_price_timeout,
*eth.contracts().settlement().address(),
Expand Down Expand Up @@ -671,6 +671,13 @@ pub async fn run(config: Configuration, shutdown_controller: ShutdownController)
awaiter.clone(),
run_loop_config.max_settlement_transaction_wait,
));
// Fast-path penalties are decoupled from the regular auction's: the handler
// only gets the calculator when explicitly enabled, so orders carry no
// penalty cap at launch.
let fast_path_penalty_cap_calculator = config
.fast_path_penalty_cap_enabled
.then(|| penalty_cap_calculator.clone())
.flatten();
let fast_path_handler = FastPathHandler::new(
eth.clone(),
persistence.clone(),
Expand All @@ -679,6 +686,7 @@ pub async fn run(config: Configuration, shutdown_controller: ShutdownController)
surplus_capturing_jit_order_owners,
settle_coordinator.clone(),
config.fast_path_submission_deadline,
fast_path_penalty_cap_calculator,
);
fast_path_handler.spawn(fast_path_receiver).await;

Expand Down
4 changes: 2 additions & 2 deletions crates/autopilot/src/solvable_orders.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ pub struct SolvableOrdersCache {
native_price_estimator: Arc<NativePriceUpdater>,
weth: Address,
protocol_fees: Arc<domain::ProtocolFees>,
penalty_cap_calculator: Option<domain::penalty_cap::PenaltyCapCalculator>,
penalty_cap_calculator: Option<Arc<domain::penalty_cap::PenaltyCapCalculator>>,
surplus_capturing_jit_order_owners: Arc<Vec<Address>>,
native_price_timeout: Duration,
settlement_contract: Address,
Expand All @@ -147,7 +147,7 @@ impl SolvableOrdersCache {
native_price_estimator: Arc<NativePriceUpdater>,
weth: Address,
protocol_fees: Arc<domain::ProtocolFees>,
penalty_cap_calculator: Option<domain::penalty_cap::PenaltyCapCalculator>,
penalty_cap_calculator: Option<Arc<domain::penalty_cap::PenaltyCapCalculator>>,
surplus_capturing_jit_order_owners: Arc<Vec<Address>>,
native_price_timeout: Duration,
settlement_contract: Address,
Expand Down
12 changes: 12 additions & 0 deletions crates/configs/src/autopilot/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,13 @@ pub struct Configuration {
#[serde(default)]
pub fast_path_submission_deadline: Option<u64>,

/// Whether fast-path orders get the CIP-87 penalty cap. Off at launch so
/// fast-path penalties stay decoupled from the regular auction's; enable
/// once solvers understand the feature. Has no effect unless `penalty_cap`
/// is also configured.
#[serde(default)]
pub fast_path_penalty_cap_enabled: bool,

/// Configurations for price estimation (tenderly, rate limiting, CoinGecko,
/// 1inch, quote verification, balance overrides, etc.).
#[serde(default)]
Expand Down Expand Up @@ -250,6 +257,7 @@ impl Configuration {
http_client: Default::default(),
order_quoting: TestDefault::test_default(),
fast_path_submission_deadline: None,
fast_path_penalty_cap_enabled: false,
price_estimation: TestDefault::test_default(),
balance_cache: TestDefault::test_default(),
}
Expand Down Expand Up @@ -284,6 +292,7 @@ impl Configuration {
http_client: Default::default(),
order_quoting: TestDefault::test_default(),
fast_path_submission_deadline: None,
fast_path_penalty_cap_enabled: false,
price_estimation: TestDefault::test_default(),
balance_cache: TestDefault::test_default(),
}
Expand Down Expand Up @@ -335,6 +344,7 @@ mod tests {
max-auction-age = "10m"
native-price-timeout = "3s"
fast-path-submission-deadline = 3
fast-path-penalty-cap-enabled = true

[[drivers]]
name = "solver1"
Expand Down Expand Up @@ -488,6 +498,7 @@ mod tests {
assert_eq!(config.max_auction_age, Duration::from_secs(600));
assert_eq!(config.native_price_timeout, Duration::from_secs(3));
assert_eq!(config.fast_path_submission_deadline, Some(3));
assert!(config.fast_path_penalty_cap_enabled);

assert_eq!(config.balance_cache.eviction_time, Duration::from_secs(10));
assert_eq!(
Expand Down Expand Up @@ -558,6 +569,7 @@ mod tests {
assert_eq!(config.max_auction_age, Duration::from_secs(300));
assert_eq!(config.native_price_timeout, Duration::ZERO);
assert_eq!(config.fast_path_submission_deadline, None);
assert!(!config.fast_path_penalty_cap_enabled);
}

#[test]
Expand Down
138 changes: 138 additions & 0 deletions crates/e2e/tests/e2e/fast_path_settle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use {
FeePolicyKind as ConfigFeePolicyKind,
FeePolicyOrderClass as ConfigFeePolicyOrderClass,
},
penalty_cap::PenaltyCapConfig,
solver::Solver,
},
order_quoting::{ExternalSolver, OrderQuoting},
Expand Down Expand Up @@ -118,6 +119,12 @@ async fn local_node_fast_path_settles_across_split_configs() {
run_test(fast_path_settles_across_split_configs).await;
}

#[tokio::test]
#[ignore]
async fn local_node_fast_path_penalty_cap() {
run_test(fast_path_penalty_cap).await;
}

async fn fast_path_settle(web3: Web3) {
let mut onchain = OnchainComponents::deploy(web3.clone()).await;

Expand Down Expand Up @@ -222,6 +229,137 @@ async fn fast_path_settle(web3: Web3) {
);
}

/// A fast-path order settled out of competition gets its CIP-87 penalty cap
/// persisted on the promoted `competition_auctions` row, like a regular
/// auction.
async fn fast_path_penalty_cap(web3: Web3) {
let mut onchain = OnchainComponents::deploy(web3.clone()).await;

let [solver] = onchain.make_solvers(10u64.eth()).await;
let [trader] = onchain.make_accounts(10u64.eth()).await;
let [token] = onchain
.deploy_tokens_with_weth_uni_v2_pools(1_000u64.eth(), 1_000u64.eth())
.await;

let sell_amount = 1u64.eth();
onchain
.contracts()
.weth
.approve(onchain.contracts().allowance, sell_amount)
.from(trader.address())
.send_and_watch()
.await
.unwrap();
onchain
.contracts()
.weth
.deposit()
.from(trader.address())
.value(sell_amount)
.send_and_watch()
.await
.unwrap();

tracing::info!("Starting services.");
let services = Services::new(&onchain).await;
let exclusivity = Duration::from_secs(300);
// Enable penalty caps alongside the fast path. `with_fast_path_exclusivity`
// preserves this via its `..autopilot` spread.
let base = AutopilotConfiguration {
penalty_cap: Some(PenaltyCapConfig {
default_factor: 0.0004.try_into().unwrap(),
absolute_cap_usd: 20.,
// WETH as the USD reference: its native price is 1 by definition, so
// the bound is 20 ETH. The test only cares about the plumbing.
usd_reference_token: *onchain.contracts().weth.address(),
overrides: vec![],
}),
// Opt the fast path into penalties (off by default).
fast_path_penalty_cap_enabled: true,
..AutopilotConfiguration::test("test_solver", solver.address())
};
let (autopilot_config, orderbook_config) = with_fast_path_exclusivity(
base,
configs::orderbook::Configuration::test_default(),
exclusivity,
);
services
.start_protocol_with_args(autopilot_config, orderbook_config, solver)
.await;

let app_data = r#"{"metadata":{"enableFastPath":true}}"#.to_string();

tracing::info!("Quoting with enableFastPath.");
let quote_request = OrderQuoteRequest {
from: trader.address(),
sell_token: *onchain.contracts().weth.address(),
buy_token: *token.address(),
side: OrderQuoteSide::Sell {
sell_amount: SellAmount::BeforeFee {
value: NonZeroU256::try_from(sell_amount).unwrap(),
},
},
app_data: OrderCreationAppData::Full {
full: app_data.clone(),
},
..Default::default()
};
let quote = services.submit_quote(&quote_request).await.unwrap();
let quote_id = quote.id.expect("fast-path quote should carry an id");

tracing::info!("Placing the fast-path order.");
let order = OrderCreation {
quote_id: Some(quote_id),
sell_token: *onchain.contracts().weth.address(),
sell_amount,
buy_token: *token.address(),
buy_amount: quote.quote.buy_amount,
valid_to: model::time::now_in_epoch_seconds() + 3600,
kind: OrderKind::Sell,
app_data: OrderCreationAppData::Full { full: app_data },
..Default::default()
}
.sign(
EcdsaSigningScheme::Eip712,
&onchain.contracts().domain_separator,
&trader.signer,
);
let placed_at = std::time::Instant::now();
let uid = services.create_order(&order).await.unwrap();

tracing::info!("Waiting for the fast-path settlement.");
wait_for_condition(TIMEOUT, || async {
services
.get_order(&uid)
.await
.is_ok_and(|order| order.metadata.status == OrderStatus::Fulfilled)
})
.await
.unwrap();

// Fulfilled well within the exclusivity window ⇒ the fast-path handler
// settled it (and therefore wrote the promoted competition row), not the
// regular-auction fallback.
let elapsed = placed_at.elapsed();
assert!(
elapsed < exclusivity / 2,
"settled after {elapsed:?}; regular fallback would have waited out {exclusivity:?}",
);

let caps = crate::database::penalty_caps_of_order(services.db(), &uid).await;
assert!(
caps.iter().any(bigdecimal::Signed::is_positive),
"fast-path competition row should carry a positive penalty cap, got {caps:?}",
);

// The settled trade exposes that same cap for accounting.
let trade = services.get_trades(&uid).await.unwrap().remove(0);
let cap = trade
.penalty_cap_native
.expect("settled fast-path trade carries the auction's penalty cap");
assert!(!cap.is_zero());
}

/// Regression test for the quoter/solver fast-path cache split.
///
/// A quoter config (`test_quote`) and a solve config (`test_solver`) share one
Expand Down
Loading