From 0024bcb7991de39ea9fa3153233932623041aa2d Mon Sep 17 00:00:00 2001 From: Felix Leupold <1200333+fleupold@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:52:13 +0200 Subject: [PATCH 1/3] [CIP-87] Expose penalty cap on /trades API --- crates/database/src/auction.rs | 103 +++++++++++++++++++++++- crates/e2e/tests/e2e/penalty_cap.rs | 20 +++++ crates/model/src/trade.rs | 10 ++- crates/orderbook/openapi.yml | 9 +++ crates/orderbook/src/database/trades.rs | 53 +++++++----- 5 files changed, 174 insertions(+), 21 deletions(-) diff --git a/crates/database/src/auction.rs b/crates/database/src/auction.rs index 8b66ce3781..85222aaed7 100644 --- a/crates/database/src/auction.rs +++ b/crates/database/src/auction.rs @@ -1,8 +1,8 @@ use { crate::{Address, OrderUid}, bigdecimal::BigDecimal, - sqlx::{Connection, PgConnection, types::JsonValue}, - std::ops::DerefMut, + sqlx::{Connection, PgConnection, QueryBuilder, types::JsonValue}, + std::{collections::HashMap, ops::DerefMut}, tracing::instrument, }; @@ -191,10 +191,109 @@ pub async fn fetch_latest_token_price( Ok(price) } +/// Fetches the penalty caps recorded in `competition_auctions` for the given +/// `(auction_id, order_uid)` keys, in native token wei. A key is absent from +/// the result when its auction has no competition data, the order wasn't part +/// of that auction, or penalties were disabled for it. +#[instrument(skip_all)] +pub async fn penalty_caps( + ex: &mut PgConnection, + keys: &[(AuctionId, OrderUid)], +) -> Result, sqlx::Error> { + if keys.is_empty() { + return Ok(HashMap::new()); + } + + let mut query_builder = QueryBuilder::new( + "SELECT ca.id AS auction_id, vals.order_uid, \ + ca.penalty_caps_native[array_position(ca.order_uids, vals.order_uid)] AS penalty_cap \ + FROM competition_auctions ca INNER JOIN (VALUES ", + ); + for (i, (auction_id, order_uid)) in keys.iter().enumerate() { + if i > 0 { + query_builder.push(", "); + } + query_builder + .push("(") + .push_bind(auction_id) + .push(", ") + .push_bind(order_uid) + .push(")"); + } + query_builder.push(") AS vals(auction_id, order_uid) ON ca.id = vals.auction_id"); + + #[derive(sqlx::FromRow)] + struct Row { + auction_id: AuctionId, + order_uid: OrderUid, + penalty_cap: Option, + } + let rows: Vec = query_builder.build_query_as().fetch_all(ex).await?; + Ok(rows + .into_iter() + .filter_map(|row| { + row.penalty_cap + .map(|cap| ((row.auction_id, row.order_uid), cap)) + }) + .collect()) +} + #[cfg(test)] mod tests { use {super::*, crate::byte_array::ByteArray}; + #[tokio::test] + #[ignore] + async fn postgres_penalty_caps() { + let mut db = PgConnection::connect("postgresql://").await.unwrap(); + let mut db = db.begin().await.unwrap(); + crate::clear_DANGER_(&mut db).await.unwrap(); + + let capped = ByteArray([1u8; 56]); + let uncapped = ByteArray([2u8; 56]); + let unknown = ByteArray([3u8; 56]); + let auction = |id, penalty_caps_native| Auction { + id, + block: 1, + deadline: 2, + order_uids: vec![capped, uncapped], + price_tokens: vec![], + price_values: vec![], + surplus_capturing_jit_order_owners: vec![], + penalty_caps_native, + }; + // Auction 1 recorded caps, auction 2 had penalties disabled, auction 3 + // has no competition data at all. + save( + &mut db, + auction(1, Some(vec![BigDecimal::from(1234), BigDecimal::from(0)])), + ) + .await + .unwrap(); + save(&mut db, auction(2, None)).await.unwrap(); + + let caps = penalty_caps( + &mut db, + &[ + (1, capped), + (1, uncapped), + (1, unknown), + (2, capped), + (3, capped), + ], + ) + .await + .unwrap(); + assert_eq!( + caps, + HashMap::from([ + ((1, capped), BigDecimal::from(1234)), + ((1, uncapped), BigDecimal::from(0)), + ]) + ); + assert!(penalty_caps(&mut db, &[]).await.unwrap().is_empty()); + } + #[tokio::test] #[ignore] async fn postgres_roundtrip() { diff --git a/crates/e2e/tests/e2e/penalty_cap.rs b/crates/e2e/tests/e2e/penalty_cap.rs index 3ae60c5d70..80cb0ea09d 100644 --- a/crates/e2e/tests/e2e/penalty_cap.rs +++ b/crates/e2e/tests/e2e/penalty_cap.rs @@ -94,4 +94,24 @@ async fn penalty_cap(web3: Web3) { }) .await .unwrap(); + + // Once the order gets settled, the trade exposes the cap of the auction + // that settled it. + wait_for_condition(TIMEOUT, || async { + onchain.mint_block().await; + !services.get_trades(&uid).await.unwrap().is_empty() + }) + .await + .unwrap(); + let trade = services.get_trades(&uid).await.unwrap().remove(0); + let cap = trade + .penalty_cap_native + .expect("settled trade carries the auction's penalty cap"); + assert!(!cap.is_zero()); + let caps = crate::database::penalty_caps_of_order(services.db(), &uid).await; + assert!( + caps.iter() + .any(|db_cap| number::conversions::big_decimal_to_u256(db_cap) == Some(cap)), + "trade cap {cap} not among the caps persisted for the order" + ); } diff --git a/crates/model/src/trade.rs b/crates/model/src/trade.rs index 586c2afa74..4ab68a5227 100644 --- a/crates/model/src/trade.rs +++ b/crates/model/src/trade.rs @@ -3,8 +3,9 @@ use { crate::{fee_policy::ExecutedProtocolFee, order::OrderUid}, - alloy_primitives::{Address, B256}, + alloy_primitives::{Address, B256, U256}, num::BigUint, + number::serialization::HexOrDecimalU256, serde::Serialize, serde_with::{DisplayFromStr, serde_as}, }; @@ -30,6 +31,11 @@ pub struct Trade { // Settlement Data pub tx_hash: Option, pub executed_protocol_fees: Vec, + /// Cap on the penalty the winning solver could have incurred. Absent when + /// the auction had penalties disabled or the trade can't be attributed + /// to an auction. + #[serde_as(as = "Option")] + pub penalty_cap_native: Option, } #[cfg(test)] @@ -56,6 +62,7 @@ mod tests { "sellToken": "0x000000000000000000000000000000000000000a", "buyToken": "0x0000000000000000000000000000000000000009", "txHash": "0x0000000000000000000000000000000000000000000000000000000000000040", + "penaltyCapNative": "1000000000000000", "executedProtocolFees": [ { "amount": "5", @@ -104,6 +111,7 @@ mod tests { buy_token: Address::with_last_byte(9), sell_token: Address::with_last_byte(10), tx_hash: Some(B256::with_last_byte(64)), + penalty_cap_native: Some(U256::from(1_000_000_000_000_000u64)), executed_protocol_fees: vec![ ExecutedProtocolFee { amount: U256::from(5u64), diff --git a/crates/orderbook/openapi.yml b/crates/orderbook/openapi.yml index 06f7be972d..59c3902809 100644 --- a/crates/orderbook/openapi.yml +++ b/crates/orderbook/openapi.yml @@ -1791,6 +1791,15 @@ components: type: array items: $ref: "#/components/schemas/ExecutedProtocolFee" + penaltyCapNative: + description: >- + Cap on the penalty the winning solver could incur for not executing + the order in the auction this trade settled, denominated in native + token wei. Absent when the auction had penalties disabled or the + trade can't be attributed to an auction. + allOf: + - $ref: "#/components/schemas/BigUint" + nullable: true required: - blockNumber - logIndex diff --git a/crates/orderbook/src/database/trades.rs b/crates/orderbook/src/database/trades.rs index 0066baf1a1..f4011038b8 100644 --- a/crates/orderbook/src/database/trades.rs +++ b/crates/orderbook/src/database/trades.rs @@ -4,7 +4,8 @@ use { anyhow::{Context, Result}, database::{byte_array::ByteArray, trades::TradesQueryRow}, model::{fee_policy::ExecutedProtocolFee, order::OrderUid, trade::Trade}, - number::conversions::big_decimal_to_big_uint, + number::conversions::{big_decimal_to_big_uint, big_decimal_to_u256}, + sqlx::types::BigDecimal, std::convert::TryInto, }; @@ -72,20 +73,25 @@ impl TradeRetrieving for Postgres { let executed_protocol_fees = self .executed_protocol_fees(auction_order_uids.as_slice()) .await?; + let penalty_caps = { + let _timer = super::Metrics::get() + .database_queries + .with_label_values(&["penalty_caps"]) + .start_timer(); + database::auction::penalty_caps(&mut ex, auction_order_uids.as_slice()).await? + }; trades .into_iter() .map(|trade| { - let executed_protocol_fees = trade + let key = trade .auction_id - .map(|auction_id| { - executed_protocol_fees - .get(&(auction_id, trade.order_uid)) - .cloned() - .unwrap_or_default() - }) + .map(|auction_id| (auction_id, trade.order_uid)); + let executed_protocol_fees = key + .and_then(|key| executed_protocol_fees.get(&key).cloned()) .unwrap_or_default(); - trade_from(trade, executed_protocol_fees) + let penalty_cap_native = key.and_then(|key| penalty_caps.get(&key).cloned()); + trade_from(trade, executed_protocol_fees, penalty_cap_native) }) .collect::>>() } @@ -133,20 +139,25 @@ impl TradeRetrievingPaginated for Postgres { let executed_protocol_fees = self .executed_protocol_fees(auction_order_uids.as_slice()) .await?; + let penalty_caps = { + let _timer = super::Metrics::get() + .database_queries + .with_label_values(&["penalty_caps"]) + .start_timer(); + database::auction::penalty_caps(&mut ex, auction_order_uids.as_slice()).await? + }; trades .into_iter() .map(|trade| { - let executed_protocol_fees = trade + let key = trade .auction_id - .map(|auction_id| { - executed_protocol_fees - .get(&(auction_id, trade.order_uid)) - .cloned() - .unwrap_or_default() - }) + .map(|auction_id| (auction_id, trade.order_uid)); + let executed_protocol_fees = key + .and_then(|key| executed_protocol_fees.get(&key).cloned()) .unwrap_or_default(); - trade_from(trade, executed_protocol_fees) + let penalty_cap_native = key.and_then(|key| penalty_caps.get(&key).cloned()); + trade_from(trade, executed_protocol_fees, penalty_cap_native) }) .collect::>>() } @@ -155,6 +166,7 @@ impl TradeRetrievingPaginated for Postgres { fn trade_from( row: TradesQueryRow, executed_protocol_fees: Vec, + penalty_cap_native: Option, ) -> Result { let block_number = row .block_number @@ -172,6 +184,10 @@ fn trade_from( let buy_token = Address::from_slice(&row.buy_token.0); let sell_token = Address::from_slice(&row.sell_token.0); let tx_hash = row.tx_hash.map(|hash| B256::from_slice(&hash.0)); + let penalty_cap_native = penalty_cap_native + .as_ref() + .map(|cap| big_decimal_to_u256(cap).context("penalty_cap_native is not a U256")) + .transpose()?; Ok(Trade { block_number, log_index, @@ -184,6 +200,7 @@ fn trade_from( sell_token, tx_hash, executed_protocol_fees, + penalty_cap_native, }) } @@ -193,6 +210,6 @@ mod tests { #[test] fn convert_trade() { - trade_from(TradesQueryRow::default(), vec![]).unwrap(); + trade_from(TradesQueryRow::default(), vec![], None).unwrap(); } } From 24599ba2f6ee8a474d8129f2a74049e203afeaaf Mon Sep 17 00:00:00 2001 From: Felix Leupold <1200333+fleupold@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:55:44 +0200 Subject: [PATCH 2/3] cleanup --- crates/database/src/auction.rs | 52 ---------------------------------- 1 file changed, 52 deletions(-) diff --git a/crates/database/src/auction.rs b/crates/database/src/auction.rs index 85222aaed7..28e40925df 100644 --- a/crates/database/src/auction.rs +++ b/crates/database/src/auction.rs @@ -242,58 +242,6 @@ pub async fn penalty_caps( mod tests { use {super::*, crate::byte_array::ByteArray}; - #[tokio::test] - #[ignore] - async fn postgres_penalty_caps() { - let mut db = PgConnection::connect("postgresql://").await.unwrap(); - let mut db = db.begin().await.unwrap(); - crate::clear_DANGER_(&mut db).await.unwrap(); - - let capped = ByteArray([1u8; 56]); - let uncapped = ByteArray([2u8; 56]); - let unknown = ByteArray([3u8; 56]); - let auction = |id, penalty_caps_native| Auction { - id, - block: 1, - deadline: 2, - order_uids: vec![capped, uncapped], - price_tokens: vec![], - price_values: vec![], - surplus_capturing_jit_order_owners: vec![], - penalty_caps_native, - }; - // Auction 1 recorded caps, auction 2 had penalties disabled, auction 3 - // has no competition data at all. - save( - &mut db, - auction(1, Some(vec![BigDecimal::from(1234), BigDecimal::from(0)])), - ) - .await - .unwrap(); - save(&mut db, auction(2, None)).await.unwrap(); - - let caps = penalty_caps( - &mut db, - &[ - (1, capped), - (1, uncapped), - (1, unknown), - (2, capped), - (3, capped), - ], - ) - .await - .unwrap(); - assert_eq!( - caps, - HashMap::from([ - ((1, capped), BigDecimal::from(1234)), - ((1, uncapped), BigDecimal::from(0)), - ]) - ); - assert!(penalty_caps(&mut db, &[]).await.unwrap().is_empty()); - } - #[tokio::test] #[ignore] async fn postgres_roundtrip() { From 4d6be0b3c14fe75958089677acb55c34c44a3682 Mon Sep 17 00:00:00 2001 From: Felix Leupold <1200333+fleupold@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:44:35 +0200 Subject: [PATCH 3/3] fix api doc comment --- crates/orderbook/openapi.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/orderbook/openapi.yml b/crates/orderbook/openapi.yml index 5f7b2c4742..67e0e5345a 100644 --- a/crates/orderbook/openapi.yml +++ b/crates/orderbook/openapi.yml @@ -1814,8 +1814,7 @@ components: description: >- Cap on the penalty the winning solver could incur for not executing the order in the auction this trade settled, denominated in native - token wei. Absent when the auction had penalties disabled or the - trade can't be attributed to an auction. + token wei. Absent when the auction had a pre CIP-87 global penalty cap. allOf: - $ref: "#/components/schemas/BigUint" nullable: true