From 7ef256bd1560c14177c2ff768fd9ee7de52bfe42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Duarte?= <15343819+jmg-duarte@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:03:03 +0100 Subject: [PATCH 1/5] Add onchain gas cost to trades and orders APIs --- crates/database/src/jit_orders.rs | 97 +++++++++- crates/database/src/order_history.rs | 59 ++++++- crates/database/src/orders.rs | 206 +++++++++++++++++++++- crates/database/src/trades.rs | 60 +++++++ crates/model/src/order.rs | 6 + crates/model/src/trade.rs | 24 ++- crates/orderbook/openapi.yml | 20 +++ crates/orderbook/src/database/orders.rs | 41 ++++- crates/orderbook/src/database/trades.rs | 40 ++++- crates/shared/src/db_order_conversions.rs | 5 + 10 files changed, 537 insertions(+), 21 deletions(-) diff --git a/crates/database/src/jit_orders.rs b/crates/database/src/jit_orders.rs index b87601686c..8715278a49 100644 --- a/crates/database/src/jit_orders.rs +++ b/crates/database/src/jit_orders.rs @@ -22,9 +22,7 @@ o.uid, o.owner, o.creation_timestamp, o.sell_token, o.buy_token, o.sell_amount, o.valid_to, NULL AS valid_from, o.app_data, o.fee_amount, o.kind, o.partially_fillable, o.signature, o.receiver, o.signing_scheme, '\x9008d19f58aabd9ed0d60971565aa8510560ab41'::bytea AS settlement_contract, o.sell_token_balance, o.buy_token_balance, 'liquidity'::OrderClass AS class, -(SELECT COALESCE(SUM(t.buy_amount), 0) FROM trades t WHERE t.order_uid = o.uid) AS sum_buy, -(SELECT COALESCE(SUM(t.sell_amount), 0) FROM trades t WHERE t.order_uid = o.uid) AS sum_sell, -(SELECT COALESCE(SUM(t.fee_amount), 0) FROM trades t WHERE t.order_uid = o.uid) AS sum_fee, +fills.sum_buy, fills.sum_sell, fills.sum_fee, fills.gas_cost, FALSE AS invalidated, FALSE AS presignature_pending, ARRAY[]::record[] AS pre_interactions, @@ -37,7 +35,7 @@ COALESCE((SELECT executed_fee_token FROM order_execution oe WHERE oe.order_uid = NULL AS full_app_data "#; -pub const FROM: &str = "jit_orders o"; +pub const FROM: &str = const_format::concatcp!("jit_orders o", orders::FILLS_JOIN); #[instrument(skip_all)] pub async fn get_by_id( @@ -195,7 +193,10 @@ mod tests { use { super::*, - crate::byte_array::ByteArray, + crate::{ + byte_array::ByteArray, + events::{Event, EventIndex, Settlement, Trade}, + }, sqlx::{Connection, PgConnection}, }; @@ -249,4 +250,90 @@ mod tests { .unwrap(); get_by_id(&mut db, &jit_order.uid).await.unwrap().unwrap(); } + + /// A JIT order pays no gas while it only provides liquidity, and a full + /// share once the auction lets its owner capture surplus. + #[tokio::test] + #[ignore] + async fn postgres_jit_order_gas_cost() { + let mut db = PgConnection::connect("postgresql://").await.unwrap(); + let mut db = db.begin().await.unwrap(); + crate::clear_DANGER_(&mut db).await.unwrap(); + + let owner = ByteArray([7; 20]); + let mut uid = [0u8; 56]; + uid[32..52].copy_from_slice(&owner.0); + let jit_order = JitOrder { + owner, + uid: ByteArray(uid), + ..Default::default() + }; + insert(&mut db, std::slice::from_ref(&jit_order)) + .await + .unwrap(); + + let event = |log_index| EventIndex { + block_number: 0, + log_index, + }; + let fill = |log_index| { + ( + event(log_index), + Event::Trade(Trade { + order_uid: jit_order.uid, + ..Default::default() + }), + ) + }; + let gas_cost = async |db: &mut crate::PgTransaction<'_>| { + get_by_id(db, &jit_order.uid) + .await + .unwrap() + .unwrap() + .gas_cost + }; + + // Each settlement settles one fill of this order on its own. + crate::events::append( + &mut db, + &[ + fill(0), + (event(1), Event::Settlement(Settlement::default())), + fill(2), + ( + event(3), + Event::Settlement(Settlement { + transaction_hash: ByteArray([2; 32]), + ..Default::default() + }), + ), + ], + ) + .await + .unwrap(); + + // Liquidity only, so this fill pays nothing. The other fill is still + // unattributed, which hides the total rather than understating it. + crate::trades::attribute_gas_cost( + &mut db, + event(1), + BigDecimal::from(100), + BigDecimal::from(10), + &[], + ) + .await + .unwrap(); + assert_eq!(gas_cost(&mut db).await, None); + + crate::trades::attribute_gas_cost( + &mut db, + event(3), + BigDecimal::from(100), + BigDecimal::from(10), + &[jit_order.owner], + ) + .await + .unwrap(); + assert_eq!(gas_cost(&mut db).await, Some(BigDecimal::from(1000))); + } } diff --git a/crates/database/src/order_history.rs b/crates/database/src/order_history.rs index 24ad9d0ebe..a23c163bd2 100644 --- a/crates/database/src/order_history.rs +++ b/crates/database/src/order_history.rs @@ -99,12 +99,12 @@ mod tests { super::*, crate::{ byte_array::ByteArray, - events::EventIndex, + events::{Event, EventIndex, Settlement, Trade}, onchain_broadcasted_orders::{OnchainOrderPlacement, insert_onchain_order}, }, chrono::{DateTime, Duration, Utc}, futures::StreamExt, - sqlx::Connection, + sqlx::{Connection, types::BigDecimal}, }; type Data = ([u8; 56], Address, DateTime); @@ -359,5 +359,60 @@ mod tests { // Unrelated address returns nothing. let none = user_orders(&mut db, &ByteArray([0xabu8; 20]), 0, Some(100)).await; assert!(none.is_empty()); + + // One fill per arm of the union. uid_a and uid_b are in `orders`, so + // they split the 1000; uid_c only provides liquidity. + let event = |log_index| EventIndex { + block_number: 0, + log_index, + }; + let fill = |log_index, order_uid| { + ( + event(log_index), + Event::Trade(Trade { + order_uid, + ..Default::default() + }), + ) + }; + crate::events::append( + &mut db, + &[ + fill(0, uid_a), + fill(1, uid_b), + fill(2, uid_c), + (event(3), Event::Settlement(Settlement::default())), + ], + ) + .await + .unwrap(); + crate::trades::attribute_gas_cost( + &mut db, + event(3), + BigDecimal::from(100), + BigDecimal::from(10), + &[], + ) + .await + .unwrap(); + let gas_costs = super::user_orders(&mut db, &owner, 0, Some(100)) + .map(|order| { + let order = order.unwrap(); + (order.uid, order.gas_cost) + }) + .collect::>() + .await; + assert_eq!( + gas_costs, + vec![ + (uid_a, Some(BigDecimal::from(500))), + // In both tables: the row the union keeps is the `orders` one. + (uid_b, Some(BigDecimal::from(500))), + // Read through the `jit_orders` arm of the union. + (uid_c, Some(BigDecimal::from(0))), + (uid_d, None), + (uid_e, None), + ] + ); } } diff --git a/crates/database/src/orders.rs b/crates/database/src/orders.rs index 42a0195a25..e6a0cbfcf7 100644 --- a/crates/database/src/orders.rs +++ b/crates/database/src/orders.rs @@ -538,6 +538,10 @@ pub struct FullOrder { pub executed_fee: BigDecimal, pub executed_fee_token: Address, pub full_app_data: Option>, + /// The order's share of its settlements' gas costs in native token wei, + /// summed across fills. `None` when any fill's cost is unknown; queries + /// that don't need it select a literal `NULL`. + pub gas_cost: Option, } impl FullOrder { @@ -632,9 +636,7 @@ o.uid, o.owner, o.creation_timestamp, o.sell_token, o.buy_token, o.sell_amount, o.valid_to, o.valid_from, o.app_data, o.fee_amount, o.kind, o.partially_fillable, o.signature, o.receiver, o.signing_scheme, o.settlement_contract, o.sell_token_balance, o.buy_token_balance, o.class, -(SELECT COALESCE(SUM(t.buy_amount), 0) FROM trades t WHERE t.order_uid = o.uid) AS sum_buy, -(SELECT COALESCE(SUM(t.sell_amount), 0) FROM trades t WHERE t.order_uid = o.uid) AS sum_sell, -(SELECT COALESCE(SUM(t.fee_amount), 0) FROM trades t WHERE t.order_uid = o.uid) AS sum_fee, +fills.sum_buy, fills.sum_sell, fills.sum_fee, fills.gas_cost, (o.cancellation_timestamp IS NOT NULL OR (SELECT COUNT(*) FROM invalidations WHERE invalidations.order_uid = o.uid) > 0 OR (SELECT COUNT(*) FROM onchain_order_invalidations onchain_c where onchain_c.uid = o.uid limit 1) > 0 @@ -658,7 +660,24 @@ COALESCE((SELECT executed_fee_token FROM order_execution oe WHERE oe.order_uid = (SELECT full_app_data FROM app_data ad WHERE o.app_data = ad.contract_app_data LIMIT 1) as full_app_data "#; -pub const FROM: &str = "orders o"; +/// Everything the order queries need from an order's fills. One probe of +/// `trades` rather than one per column, which matters because `gas_cost` is in +/// no index and so has to visit the heap. +/// +/// `gas_cost` is `NULL` unless every fill's cost is known — a bare `SUM` would +/// silently understate the total. [`SELECT`] reads this through the alias +/// `fills`, so a query needs both or neither. +pub(crate) const FILLS_JOIN: &str = r#" LEFT JOIN LATERAL ( + SELECT + COALESCE(SUM(fill.buy_amount), 0) AS sum_buy, + COALESCE(SUM(fill.sell_amount), 0) AS sum_sell, + COALESCE(SUM(fill.fee_amount), 0) AS sum_fee, + CASE WHEN COUNT(*) = COUNT(fill.gas_cost) THEN SUM(fill.gas_cost) END AS gas_cost + FROM trades fill + WHERE fill.order_uid = o.uid +) AS fills ON TRUE"#; + +pub const FROM: &str = const_format::concatcp!("orders o", FILLS_JOIN); const FULL_ORDER_WITH_QUOTE: &str = const_format::concatcp!( "SELECT ", SELECT, @@ -828,7 +847,8 @@ pub fn solvable_orders( NULL AS onchain_placement_error, COALESCE(fee_agg.executed_fee,0) AS executed_fee, COALESCE(fee_agg.executed_fee_token, lo.sell_token) AS executed_fee_token, - ad.full_app_data + ad.full_app_data, + NULL AS gas_cost FROM live_orders lo LEFT JOIN LATERAL ( SELECT NOT signed AS unsigned @@ -956,7 +976,8 @@ SELECT opo.onchain_placement_error, COALESCE(fee_agg.executed_fee,0) AS executed_fee, COALESCE(fee_agg.executed_fee_token, so.sell_token) AS executed_fee_token, - ad.full_app_data + ad.full_app_data, + NULL AS gas_cost FROM selected_orders so LEFT JOIN LATERAL ( SELECT NOT signed AS unsigned @@ -1194,6 +1215,8 @@ mod tests { assert_eq!(order.settlement_contract, full_order.settlement_contract); assert_eq!(order.sell_token_balance, full_order.sell_token_balance); assert_eq!(order.buy_token_balance, full_order.buy_token_balance); + // Never filled, so it has no gas cost rather than one of zero. + assert_eq!(full_order.gas_cost, None); } #[tokio::test] @@ -2359,6 +2382,177 @@ mod tests { } } + async fn two_orders(db: &mut PgTransaction<'_>) -> (OrderUid, OrderUid) { + let (order_a, order_b) = (ByteArray([1; 56]), ByteArray([2; 56])); + for uid in [order_a, order_b] { + insert_order( + db, + &Order { + uid, + ..Default::default() + }, + ) + .await + .unwrap(); + } + (order_a, order_b) + } + + async fn fill(db: &mut PgTransaction<'_>, order_uid: OrderUid, log_index: i64) { + crate::events::append( + db, + &[( + EventIndex { + block_number: 0, + log_index, + }, + Event::Trade(Trade { + order_uid, + ..Default::default() + }), + )], + ) + .await + .unwrap(); + } + + /// `gas_used` is attributed to the settled trades at a gas price of 10; + /// `None` leaves the settlement's cost unattributed. + async fn settle(db: &mut PgTransaction<'_>, log_index: i64, tx: u8, gas_used: Option) { + crate::events::append( + db, + &[( + EventIndex { + block_number: 0, + log_index, + }, + Event::Settlement(Settlement { + transaction_hash: ByteArray([tx; 32]), + ..Default::default() + }), + )], + ) + .await + .unwrap(); + if let Some(gas_used) = gas_used { + crate::trades::attribute_gas_cost( + db, + EventIndex { + block_number: 0, + log_index, + }, + BigDecimal::from(gas_used), + BigDecimal::from(10), + &[], + ) + .await + .unwrap(); + } + } + + /// The stored value, so a `None` can only mean a `NULL` column. + async fn order_gas(db: &mut PgConnection, uid: OrderUid) -> Option { + single_full_order_with_quote(db, &uid) + .await + .unwrap() + .unwrap() + .full_order + .gas_cost + } + + /// An order's gas cost sums its share of each settlement that filled it, + /// and becomes unknown as soon as any fill's cost is unattributed — a + /// partial sum would pass for a complete one. + #[tokio::test] + #[ignore] + async fn postgres_order_gas_cost_across_fills() { + let mut db = PgConnection::connect("postgresql://").await.unwrap(); + let mut db = db.begin().await.unwrap(); + crate::clear_DANGER_(&mut db).await.unwrap(); + let (order_a, order_b) = two_orders(&mut db).await; + + // 100 gas at price 10, split over the two trades this settlement + // settled. + fill(&mut db, order_a, 0).await; + fill(&mut db, order_b, 1).await; + settle(&mut db, 2, 0, Some(100)).await; + assert_eq!(order_gas(&mut db, order_a).await, Some(500.into())); + assert_eq!(order_gas(&mut db, order_b).await, Some(500.into())); + + // order_a fills again, alone, so it adds that settlement's whole 3000. + fill(&mut db, order_a, 3).await; + settle(&mut db, 4, 1, Some(300)).await; + let mut batch = many_full_orders_with_quotes(&mut db, &[order_a, order_b]) + .await + .unwrap(); + batch.sort_by_key(|order| order.full_order.uid.0); + assert_eq!( + batch + .iter() + .map(|order| (order.full_order.uid, order.full_order.gas_cost.clone())) + .collect::>(), + vec![(order_a, Some(3500.into())), (order_b, Some(500.into()))] + ); + + // A fill that was never attributed makes order_a's total unknown. + fill(&mut db, order_a, 5).await; + settle(&mut db, 6, 2, None).await; + assert!(order_gas(&mut db, order_a).await.is_none()); + assert_eq!(order_gas(&mut db, order_b).await, Some(500.into())); + } + + /// The cost [`full_orders_in_tx`] reports for an order covers *all* of its + /// fills, not only the ones the requested transaction settled. + /// + /// The query joins `trades`, so an order it filled twice comes back twice, + /// each row repeating that same total: summing `gas_cost` over the rows + /// double counts. + #[tokio::test] + #[ignore] + async fn postgres_orders_in_tx_gas_cost() { + let mut db = PgConnection::connect("postgresql://").await.unwrap(); + let mut db = db.begin().await.unwrap(); + crate::clear_DANGER_(&mut db).await.unwrap(); + let (order_a, order_b) = two_orders(&mut db).await; + + // The first settlement splits 1000 over 3 trades, two of them + // order_a's. The second gives order_a's next fill all of its 3000. + fill(&mut db, order_a, 0).await; + fill(&mut db, order_b, 1).await; + fill(&mut db, order_a, 2).await; + settle(&mut db, 3, 0, Some(100)).await; + fill(&mut db, order_a, 4).await; + settle(&mut db, 5, 1, Some(300)).await; + + let orders_in = async |db: &mut PgTransaction<'_>, tx: u8| { + let mut orders = full_orders_in_tx(db, &ByteArray([tx; 32])) + .map_ok(|order| (order.uid, order.gas_cost)) + .try_collect::>() + .await + .unwrap(); + // The query does not order its rows. + orders.sort_by_key(|(uid, _)| uid.0); + orders + }; + + // 333 + 333 for order_a's two fills here, plus 3000 from the fill the + // *other* transaction settled — repeated once per fill. + assert_eq!( + orders_in(&mut db, 0).await, + vec![ + (order_a, Some(3666.into())), + (order_a, Some(3666.into())), + (order_b, Some(333.into())), + ] + ); + + // One fill here, so one row, still carrying the other transaction's. + assert_eq!( + orders_in(&mut db, 1).await, + vec![(order_a, Some(3666.into()))] + ); + } + #[tokio::test] #[ignore] async fn postgres_latest_settlement_block() { diff --git a/crates/database/src/trades.rs b/crates/database/src/trades.rs index c354ba079d..fe8fab203f 100644 --- a/crates/database/src/trades.rs +++ b/crates/database/src/trades.rs @@ -26,6 +26,11 @@ pub struct TradesQueryRow { pub sell_token: Address, pub tx_hash: Option, pub auction_id: Option, + /// This trade's share of its settlement's gas cost in native token wei, as + /// attributed by [`attribute_gas_cost`]. `NULL` for settlements observed + /// before the migration that added the column, `0` for a JIT order that + /// only provided liquidity. + pub gas_cost: Option, } pub fn trades<'a>( @@ -46,6 +51,7 @@ SELECT o.owner, o.buy_token, o.sell_token, + t.gas_cost, settlement.tx_hash, settlement.auction_id"#; @@ -183,6 +189,11 @@ pub async fn get_trades_for_settlement( /// settlement. As with `settlements.gas_used`, a transaction with two /// settlements attributes its full cost twice, once per settlement. We do not /// expect this to happen. +/// +/// The `orders` test only holds once the indexer that writes an on-chain +/// order's row has caught up with the settlement's block, which is why the +/// autopilot attributes from `run_optional_maintenance`. Attributing earlier +/// would permanently class such an order as liquidity-only. #[instrument(skip_all)] pub async fn attribute_gas_cost( ex: &mut PgTransaction<'_>, @@ -993,6 +1004,55 @@ mod tests { ); } + /// The trades query reports the share stored for each trade, and no cost + /// at all for a trade whose settlement was never attributed. + #[tokio::test] + #[ignore] + async fn postgres_trades_report_attributed_gas_cost() { + let mut db = PgConnection::connect("postgresql://").await.unwrap(); + let mut db = db.begin().await.unwrap(); + crate::clear_DANGER_(&mut db).await.unwrap(); + + let users_and_orders = generate_owners_and_order_ids(&[2]).await; + let (owner, orders) = &users_and_orders[0]; + let event = |log_index| EventIndex { + block_number: 0, + log_index, + }; + + // Two settlements in one block: the first settles both orders, the + // second only order[0]'s next fill. + add_order_and_trade(&mut db, *owner, orders[0], event(0), None, None).await; + add_order_and_trade(&mut db, *owner, orders[1], event(1), None, None).await; + let first = event(2); + add_settlement(&mut db, first, Default::default(), ByteArray([1; 32]), 1).await; + add_trade(&mut db, *owner, orders[0], event(3), None, None).await; + let second = event(4); + add_settlement(&mut db, second, Default::default(), ByteArray([2; 32]), 2).await; + + // Only the first settlement is attributed. + attribute_gas_cost(&mut db, first, 100.into(), 10.into(), &[]) + .await + .unwrap(); + + let mut rows = trades(&mut db, Some(owner), None, 0, 1000) + .into_inner() + .await + .unwrap(); + rows.sort_by_key(|row| row.log_index); + assert_eq!( + rows.iter() + .map(|row| (row.order_uid, row.gas_cost.clone(), row.tx_hash)) + .collect::>(), + vec![ + // 1000 wei split between the first settlement's 2 trades. + (orders[0], Some(500.into()), Some(ByteArray([1; 32]))), + (orders[1], Some(500.into()), Some(ByteArray([1; 32]))), + (orders[0], None, Some(ByteArray([2; 32]))), + ] + ); + } + #[tokio::test] #[ignore] async fn postgres_token_first_trade_block() { diff --git a/crates/model/src/order.rs b/crates/model/src/order.rs index cffaf361ee..4aab1f9b4c 100644 --- a/crates/model/src/order.rs +++ b/crates/model/src/order.rs @@ -712,6 +712,12 @@ pub struct OrderMetadata { #[serde_as(as = "HexOrDecimalU256")] pub executed_fee: U256, pub executed_fee_token: Address, + /// The order's estimated share of its settlements' gas costs, in native + /// token wei, summed across its fills. `None` unless the cost of every + /// fill is known. + #[serde_as(as = "Option")] + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gas_cost: Option, pub invalidated: bool, pub status: OrderStatus, #[serde(flatten)] diff --git a/crates/model/src/trade.rs b/crates/model/src/trade.rs index 586c2afa74..3e40aa185c 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,13 @@ pub struct Trade { // Settlement Data pub tx_hash: Option, pub executed_protocol_fees: Vec, + /// The trade's estimated share of its settlement's gas cost, in native + /// token wei. `None` if the settlement predates this being recorded, `0` + /// for a JIT order that only provided liquidity for the settlement's user + /// trades. + #[serde_as(as = "Option")] + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gas_cost: Option, } #[cfg(test)] @@ -56,6 +64,7 @@ mod tests { "sellToken": "0x000000000000000000000000000000000000000a", "buyToken": "0x0000000000000000000000000000000000000009", "txHash": "0x0000000000000000000000000000000000000000000000000000000000000040", + "gasCost": "3000000", "executedProtocolFees": [ { "amount": "5", @@ -104,6 +113,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)), + gas_cost: Some(U256::from(3_000_000u64)), executed_protocol_fees: vec![ ExecutedProtocolFee { amount: U256::from(5u64), @@ -140,6 +150,18 @@ mod tests { assert_json_matches!(serialized, value); } + #[test] + fn unknown_gas_cost_is_omitted() { + let serialized = serde_json::to_value(Trade::default()).unwrap(); + assert!(serialized.get("gasCost").is_none()); + assert_eq!( + serde_json::from_value::(serialized) + .unwrap() + .gas_cost, + None + ); + } + #[test] fn debug_trade_data() { dbg!(Trade::default()); diff --git a/crates/orderbook/openapi.yml b/crates/orderbook/openapi.yml index 3c636fed5b..242e1ba478 100644 --- a/crates/orderbook/openapi.yml +++ b/crates/orderbook/openapi.yml @@ -1406,6 +1406,16 @@ components: allOf: - $ref: "#/components/schemas/Address" nullable: false + gasCost: + description: > + Estimated gas cost attributed to this order, in native token wei, + summed across its fills. Omitted unless the cost of every fill is + known, so an order that is not yet settled, or any of whose fills + predate this data being recorded, reports no cost rather than a + partial one. Only settlements observed after this field was + introduced carry a cost, so it is absent for most historical orders. + allOf: + - $ref: "#/components/schemas/BigUint" fullAppData: description: > Full `appData`, which the contract-level `appData` is a hash of. See @@ -1785,6 +1795,16 @@ components: type: array items: $ref: "#/components/schemas/ExecutedProtocolFee" + gasCost: + description: > + Estimated gas cost attributed to this trade, in native token wei: + the settlement's gas cost split equally between the user trades it + settled, not weighted by the gas each one consumed. Omitted if the + settlement predates this being recorded, which is the case for most + historical trades, and `0` for a JIT order that only provided + liquidity for the settlement's user trades. + allOf: + - $ref: "#/components/schemas/BigUint" required: - blockNumber - logIndex diff --git a/crates/orderbook/src/database/orders.rs b/crates/orderbook/src/database/orders.rs index 781a72b122..7be84125e5 100644 --- a/crates/orderbook/src/database/orders.rs +++ b/crates/orderbook/src/database/orders.rs @@ -611,6 +611,11 @@ fn full_order_with_quote_into_model_order( executed_fee: big_decimal_to_u256(&order.executed_fee) .context("executed fee is not a valid u256")?, executed_fee_token: Address::new(order.executed_fee_token.0), + gas_cost: order + .gas_cost + .as_ref() + .map(|cost| big_decimal_to_u256(cost).context("gas cost is not a valid u256")) + .transpose()?, invalidated: order.invalidated, status, is_liquidity_order: class == OrderClass::Liquidity, @@ -680,7 +685,7 @@ fn is_buy_order_filled(amount: &BigDecimal, executed_amount: &BigDecimal) -> boo mod tests { use { super::*, - alloy::primitives::Address, + alloy::primitives::{Address, U256}, chrono::Duration, database::{ byte_array::ByteArray, @@ -702,11 +707,9 @@ mod tests { std::sync::atomic::{AtomicI64, Ordering}, }; - #[test] - fn order_status() { + fn order_row() -> FullOrder { let valid_to_timestamp = Utc::now() + Duration::days(1); - - let order_row = || FullOrder { + FullOrder { uid: ByteArray([0; 56]), owner: ByteArray([0; 20]), creation_timestamp: Utc::now(), @@ -740,8 +743,36 @@ mod tests { executed_fee: Default::default(), executed_fee_token: ByteArray([1; 20]), // TODO surplus token full_app_data: Default::default(), + gas_cost: None, + } + } + + /// An unrepresentable cost is an error, not a silent absence that would + /// read as "never attributed". + #[test] + fn convert_order_gas_cost() { + let convert = |gas_cost| { + full_order_with_quote_into_model_order( + FullOrder { + gas_cost, + ..order_row() + }, + None, + ) }; + assert_eq!( + convert(Some(BigDecimal::from(1000))) + .unwrap() + .metadata + .gas_cost, + Some(U256::from(1000)) + ); + assert_eq!(convert(None).unwrap().metadata.gas_cost, None); + assert!(convert(Some(BigDecimal::from(-1))).is_err()); + } + #[test] + fn order_status() { // Open - sell (filled - 0%) assert_eq!(calculate_status(&order_row()), OrderStatus::Open); diff --git a/crates/orderbook/src/database/trades.rs b/crates/orderbook/src/database/trades.rs index 0b2bf5bd08..5bda4edc75 100644 --- a/crates/orderbook/src/database/trades.rs +++ b/crates/orderbook/src/database/trades.rs @@ -4,7 +4,7 @@ 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}, std::convert::TryInto, }; @@ -169,6 +169,11 @@ 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 gas_cost = row + .gas_cost + .as_ref() + .map(|cost| big_decimal_to_u256(cost).context("gas cost is not a valid u256")) + .transpose()?; Ok(Trade { block_number, log_index, @@ -181,15 +186,46 @@ fn trade_from( sell_token, tx_hash, executed_protocol_fees, + gas_cost, }) } #[cfg(test)] mod tests { - use super::*; + use {super::*, alloy::primitives::U256, sqlx::types::BigDecimal}; #[test] fn convert_trade() { trade_from(TradesQueryRow::default(), vec![]).unwrap(); } + + /// An unattributed cost is reported as absent rather than as zero. + #[test] + fn convert_trade_gas_cost() { + let row = TradesQueryRow { + gas_cost: Some(BigDecimal::from(1000)), + ..Default::default() + }; + assert_eq!( + trade_from(row, vec![]).unwrap().gas_cost, + Some(U256::from(1000)) + ); + assert_eq!( + trade_from(TradesQueryRow::default(), vec![]) + .unwrap() + .gas_cost, + None + ); + } + + /// An unrepresentable cost is an error, not a silent absence that would + /// read as "never attributed". + #[test] + fn convert_trade_rejects_unrepresentable_gas_cost() { + let row = TradesQueryRow { + gas_cost: Some(BigDecimal::from(-1)), + ..Default::default() + }; + assert!(trade_from(row, vec![]).is_err()); + } } diff --git a/crates/shared/src/db_order_conversions.rs b/crates/shared/src/db_order_conversions.rs index 3f114dc79b..60d9f25da0 100644 --- a/crates/shared/src/db_order_conversions.rs +++ b/crates/shared/src/db_order_conversions.rs @@ -86,6 +86,11 @@ pub fn full_order_into_model_order(order: database::orders::FullOrder) -> Result executed_fee: big_decimal_to_u256(&order.executed_fee) .context("executed fee is not a valid u256")?, executed_fee_token: Address::new(order.executed_fee_token.0), + gas_cost: order + .gas_cost + .as_ref() + .map(|cost| big_decimal_to_u256(cost).context("gas cost is not a valid u256")) + .transpose()?, invalidated: order.invalidated, status, is_liquidity_order: class == OrderClass::Liquidity, From e33ad9ecfb73681242758fec6138cccfb21be132 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Duarte?= <15343819+jmg-duarte@users.noreply.github.com> Date: Mon, 24 Aug 2026 09:07:06 +0100 Subject: [PATCH 2/5] wip cleaning more --- crates/database/src/jit_orders.rs | 14 +++++++++----- crates/database/src/orders.rs | 31 +++++++++---------------------- crates/database/src/trades.rs | 14 ++++++++++++++ 3 files changed, 32 insertions(+), 27 deletions(-) diff --git a/crates/database/src/jit_orders.rs b/crates/database/src/jit_orders.rs index 8715278a49..93025b4df1 100644 --- a/crates/database/src/jit_orders.rs +++ b/crates/database/src/jit_orders.rs @@ -17,12 +17,15 @@ use { tracing::instrument, }; -pub const SELECT: &str = r#" +pub const SELECT: &str = const_format::concatcp!( + r#" o.uid, o.owner, o.creation_timestamp, o.sell_token, o.buy_token, o.sell_amount, o.buy_amount, o.valid_to, NULL AS valid_from, o.app_data, o.fee_amount, o.kind, o.partially_fillable, o.signature, o.receiver, o.signing_scheme, '\x9008d19f58aabd9ed0d60971565aa8510560ab41'::bytea AS settlement_contract, o.sell_token_balance, o.buy_token_balance, 'liquidity'::OrderClass AS class, -fills.sum_buy, fills.sum_sell, fills.sum_fee, fills.gas_cost, +(SELECT COALESCE(SUM(t.buy_amount), 0) FROM trades t WHERE t.order_uid = o.uid) AS sum_buy, +(SELECT COALESCE(SUM(t.sell_amount), 0) FROM trades t WHERE t.order_uid = o.uid) AS sum_sell, +(SELECT COALESCE(SUM(t.fee_amount), 0) FROM trades t WHERE t.order_uid = o.uid) AS sum_fee, FALSE AS invalidated, FALSE AS presignature_pending, ARRAY[]::record[] AS pre_interactions, @@ -32,10 +35,11 @@ NULL AS onchain_user, NULL AS onchain_placement_error, COALESCE((SELECT SUM(executed_fee) FROM order_execution oe WHERE oe.order_uid = o.uid), 0) as executed_fee, COALESCE((SELECT executed_fee_token FROM order_execution oe WHERE oe.order_uid = o.uid LIMIT 1), o.sell_token) as executed_fee_token, -- TODO surplus token -NULL AS full_app_data -"#; +NULL AS full_app_data, "#, + crate::trades::ORDER_GAS_COST, +); -pub const FROM: &str = const_format::concatcp!("jit_orders o", orders::FILLS_JOIN); +pub const FROM: &str = "jit_orders o"; #[instrument(skip_all)] pub async fn get_by_id( diff --git a/crates/database/src/orders.rs b/crates/database/src/orders.rs index e6a0cbfcf7..534443ff38 100644 --- a/crates/database/src/orders.rs +++ b/crates/database/src/orders.rs @@ -631,12 +631,15 @@ impl FullOrderWithQuote { // SET enable_nestloop = false; // to get a better idea of what indexes postgres *could* use even if it decides // that with the current amount of data this wouldn't be better. -pub const SELECT: &str = r#" +pub const SELECT: &str = const_format::concatcp!( + r#" o.uid, o.owner, o.creation_timestamp, o.sell_token, o.buy_token, o.sell_amount, o.buy_amount, o.valid_to, o.valid_from, o.app_data, o.fee_amount, o.kind, o.partially_fillable, o.signature, o.receiver, o.signing_scheme, o.settlement_contract, o.sell_token_balance, o.buy_token_balance, o.class, -fills.sum_buy, fills.sum_sell, fills.sum_fee, fills.gas_cost, +(SELECT COALESCE(SUM(t.buy_amount), 0) FROM trades t WHERE t.order_uid = o.uid) AS sum_buy, +(SELECT COALESCE(SUM(t.sell_amount), 0) FROM trades t WHERE t.order_uid = o.uid) AS sum_sell, +(SELECT COALESCE(SUM(t.fee_amount), 0) FROM trades t WHERE t.order_uid = o.uid) AS sum_fee, (o.cancellation_timestamp IS NOT NULL OR (SELECT COUNT(*) FROM invalidations WHERE invalidations.order_uid = o.uid) > 0 OR (SELECT COUNT(*) FROM onchain_order_invalidations onchain_c where onchain_c.uid = o.uid limit 1) > 0 @@ -657,27 +660,11 @@ array(Select (p.target, p.value, p.data) from interactions p where p.order_uid = (SELECT onchain_o.placement_error from onchain_placed_orders onchain_o where onchain_o.uid = o.uid limit 1) as onchain_placement_error, COALESCE((SELECT SUM(executed_fee) FROM order_execution oe WHERE oe.order_uid = o.uid), 0) as executed_fee, COALESCE((SELECT executed_fee_token FROM order_execution oe WHERE oe.order_uid = o.uid LIMIT 1), o.sell_token) as executed_fee_token, -- TODO surplus token -(SELECT full_app_data FROM app_data ad WHERE o.app_data = ad.contract_app_data LIMIT 1) as full_app_data -"#; +(SELECT full_app_data FROM app_data ad WHERE o.app_data = ad.contract_app_data LIMIT 1) as full_app_data, "#, + crate::trades::ORDER_GAS_COST, +); -/// Everything the order queries need from an order's fills. One probe of -/// `trades` rather than one per column, which matters because `gas_cost` is in -/// no index and so has to visit the heap. -/// -/// `gas_cost` is `NULL` unless every fill's cost is known — a bare `SUM` would -/// silently understate the total. [`SELECT`] reads this through the alias -/// `fills`, so a query needs both or neither. -pub(crate) const FILLS_JOIN: &str = r#" LEFT JOIN LATERAL ( - SELECT - COALESCE(SUM(fill.buy_amount), 0) AS sum_buy, - COALESCE(SUM(fill.sell_amount), 0) AS sum_sell, - COALESCE(SUM(fill.fee_amount), 0) AS sum_fee, - CASE WHEN COUNT(*) = COUNT(fill.gas_cost) THEN SUM(fill.gas_cost) END AS gas_cost - FROM trades fill - WHERE fill.order_uid = o.uid -) AS fills ON TRUE"#; - -pub const FROM: &str = const_format::concatcp!("orders o", FILLS_JOIN); +pub const FROM: &str = "orders o"; const FULL_ORDER_WITH_QUOTE: &str = const_format::concatcp!( "SELECT ", SELECT, diff --git a/crates/database/src/trades.rs b/crates/database/src/trades.rs index fe8fab203f..e145ed85e6 100644 --- a/crates/database/src/trades.rs +++ b/crates/database/src/trades.rs @@ -33,6 +33,20 @@ pub struct TradesQueryRow { pub gas_cost: Option, } +/// Select-list expression summing the gas costs of the fills of an order +/// aliased `o`. `NULL` unless every fill's cost is known — a bare `SUM` would +/// silently understate the total. Read through the alias `fill` so it also +/// works in a query that joins `trades` itself. +/// +/// Unlike the executed-amount sums beside it, this probe is not covered by the +/// `trades_covering` index, so it visits the heap: measured at +45% plan cost +/// for a 1000-order page. If that starts to show on the order endpoints, add +/// `gas_cost` to that index's `INCLUDE` list to make the probe index-only +/// again. +pub(crate) const ORDER_GAS_COST: &str = "(SELECT CASE WHEN COUNT(*) = COUNT(fill.gas_cost) THEN \ + SUM(fill.gas_cost) END FROM trades fill WHERE \ + fill.order_uid = o.uid) AS gas_cost"; + pub fn trades<'a>( ex: &'a mut PgConnection, owner_filter: Option<&'a Address>, From ca2ada96af6d29ff0b8c408a2e69e91f5fcc7186 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Duarte?= <15343819+jmg-duarte@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:14:47 +0100 Subject: [PATCH 3/5] Simplify --- crates/database/src/jit_orders.rs | 91 +------------------------ crates/database/src/order_history.rs | 6 +- crates/database/src/orders.rs | 72 +++---------------- crates/database/src/trades.rs | 34 ++++----- crates/model/src/order.rs | 5 +- crates/model/src/trade.rs | 19 +----- crates/orderbook/openapi.yml | 11 ++- crates/orderbook/src/database/orders.rs | 35 ++-------- crates/orderbook/src/database/trades.rs | 33 +++------ 9 files changed, 49 insertions(+), 257 deletions(-) diff --git a/crates/database/src/jit_orders.rs b/crates/database/src/jit_orders.rs index 93025b4df1..a41db10a96 100644 --- a/crates/database/src/jit_orders.rs +++ b/crates/database/src/jit_orders.rs @@ -197,10 +197,7 @@ mod tests { use { super::*, - crate::{ - byte_array::ByteArray, - events::{Event, EventIndex, Settlement, Trade}, - }, + crate::byte_array::ByteArray, sqlx::{Connection, PgConnection}, }; @@ -254,90 +251,4 @@ mod tests { .unwrap(); get_by_id(&mut db, &jit_order.uid).await.unwrap().unwrap(); } - - /// A JIT order pays no gas while it only provides liquidity, and a full - /// share once the auction lets its owner capture surplus. - #[tokio::test] - #[ignore] - async fn postgres_jit_order_gas_cost() { - let mut db = PgConnection::connect("postgresql://").await.unwrap(); - let mut db = db.begin().await.unwrap(); - crate::clear_DANGER_(&mut db).await.unwrap(); - - let owner = ByteArray([7; 20]); - let mut uid = [0u8; 56]; - uid[32..52].copy_from_slice(&owner.0); - let jit_order = JitOrder { - owner, - uid: ByteArray(uid), - ..Default::default() - }; - insert(&mut db, std::slice::from_ref(&jit_order)) - .await - .unwrap(); - - let event = |log_index| EventIndex { - block_number: 0, - log_index, - }; - let fill = |log_index| { - ( - event(log_index), - Event::Trade(Trade { - order_uid: jit_order.uid, - ..Default::default() - }), - ) - }; - let gas_cost = async |db: &mut crate::PgTransaction<'_>| { - get_by_id(db, &jit_order.uid) - .await - .unwrap() - .unwrap() - .gas_cost - }; - - // Each settlement settles one fill of this order on its own. - crate::events::append( - &mut db, - &[ - fill(0), - (event(1), Event::Settlement(Settlement::default())), - fill(2), - ( - event(3), - Event::Settlement(Settlement { - transaction_hash: ByteArray([2; 32]), - ..Default::default() - }), - ), - ], - ) - .await - .unwrap(); - - // Liquidity only, so this fill pays nothing. The other fill is still - // unattributed, which hides the total rather than understating it. - crate::trades::attribute_gas_cost( - &mut db, - event(1), - BigDecimal::from(100), - BigDecimal::from(10), - &[], - ) - .await - .unwrap(); - assert_eq!(gas_cost(&mut db).await, None); - - crate::trades::attribute_gas_cost( - &mut db, - event(3), - BigDecimal::from(100), - BigDecimal::from(10), - &[jit_order.owner], - ) - .await - .unwrap(); - assert_eq!(gas_cost(&mut db).await, Some(BigDecimal::from(1000))); - } } diff --git a/crates/database/src/order_history.rs b/crates/database/src/order_history.rs index a23c163bd2..3078c003b2 100644 --- a/crates/database/src/order_history.rs +++ b/crates/database/src/order_history.rs @@ -360,7 +360,7 @@ mod tests { let none = user_orders(&mut db, &ByteArray([0xabu8; 20]), 0, Some(100)).await; assert!(none.is_empty()); - // One fill per arm of the union. uid_a and uid_b are in `orders`, so + // One fill per arm of the union. uid_a and uid_b are in `orders` so // they split the 1000; uid_c only provides liquidity. let event = |log_index| EventIndex { block_number: 0, @@ -406,9 +406,9 @@ mod tests { gas_costs, vec![ (uid_a, Some(BigDecimal::from(500))), - // In both tables: the row the union keeps is the `orders` one. + // In both tables; the union keeps the `orders` row. (uid_b, Some(BigDecimal::from(500))), - // Read through the `jit_orders` arm of the union. + // Read through the `jit_orders` arm. (uid_c, Some(BigDecimal::from(0))), (uid_d, None), (uid_e, None), diff --git a/crates/database/src/orders.rs b/crates/database/src/orders.rs index 534443ff38..6aff0f1de6 100644 --- a/crates/database/src/orders.rs +++ b/crates/database/src/orders.rs @@ -538,9 +538,8 @@ pub struct FullOrder { pub executed_fee: BigDecimal, pub executed_fee_token: Address, pub full_app_data: Option>, - /// The order's share of its settlements' gas costs in native token wei, - /// summed across fills. `None` when any fill's cost is unknown; queries - /// that don't need it select a literal `NULL`. + /// Share of its settlements' gas costs in native token wei, summed across + /// fills. `None` when any fill's cost is unknown. pub gas_cost: Option, } @@ -835,7 +834,7 @@ pub fn solvable_orders( COALESCE(fee_agg.executed_fee,0) AS executed_fee, COALESCE(fee_agg.executed_fee_token, lo.sell_token) AS executed_fee_token, ad.full_app_data, - NULL AS gas_cost + NULL::numeric AS gas_cost FROM live_orders lo LEFT JOIN LATERAL ( SELECT NOT signed AS unsigned @@ -964,7 +963,7 @@ SELECT COALESCE(fee_agg.executed_fee,0) AS executed_fee, COALESCE(fee_agg.executed_fee_token, so.sell_token) AS executed_fee_token, ad.full_app_data, - NULL AS gas_cost + NULL::numeric AS gas_cost FROM selected_orders so LEFT JOIN LATERAL ( SELECT NOT signed AS unsigned @@ -2403,8 +2402,8 @@ mod tests { .unwrap(); } - /// `gas_used` is attributed to the settled trades at a gas price of 10; - /// `None` leaves the settlement's cost unattributed. + /// Attributes `gas_used` to the settled trades at a gas price of 10; + /// `None` leaves the settlement unattributed. async fn settle(db: &mut PgTransaction<'_>, log_index: i64, tx: u8, gas_used: Option) { crate::events::append( db, @@ -2437,7 +2436,6 @@ mod tests { } } - /// The stored value, so a `None` can only mean a `NULL` column. async fn order_gas(db: &mut PgConnection, uid: OrderUid) -> Option { single_full_order_with_quote(db, &uid) .await @@ -2448,8 +2446,7 @@ mod tests { } /// An order's gas cost sums its share of each settlement that filled it, - /// and becomes unknown as soon as any fill's cost is unattributed — a - /// partial sum would pass for a complete one. + /// and becomes unknown as soon as any fill is unattributed. #[tokio::test] #[ignore] async fn postgres_order_gas_cost_across_fills() { @@ -2458,8 +2455,7 @@ mod tests { crate::clear_DANGER_(&mut db).await.unwrap(); let (order_a, order_b) = two_orders(&mut db).await; - // 100 gas at price 10, split over the two trades this settlement - // settled. + // 100 gas at price 10, split over this settlement's two trades. fill(&mut db, order_a, 0).await; fill(&mut db, order_b, 1).await; settle(&mut db, 2, 0, Some(100)).await; @@ -2488,58 +2484,6 @@ mod tests { assert_eq!(order_gas(&mut db, order_b).await, Some(500.into())); } - /// The cost [`full_orders_in_tx`] reports for an order covers *all* of its - /// fills, not only the ones the requested transaction settled. - /// - /// The query joins `trades`, so an order it filled twice comes back twice, - /// each row repeating that same total: summing `gas_cost` over the rows - /// double counts. - #[tokio::test] - #[ignore] - async fn postgres_orders_in_tx_gas_cost() { - let mut db = PgConnection::connect("postgresql://").await.unwrap(); - let mut db = db.begin().await.unwrap(); - crate::clear_DANGER_(&mut db).await.unwrap(); - let (order_a, order_b) = two_orders(&mut db).await; - - // The first settlement splits 1000 over 3 trades, two of them - // order_a's. The second gives order_a's next fill all of its 3000. - fill(&mut db, order_a, 0).await; - fill(&mut db, order_b, 1).await; - fill(&mut db, order_a, 2).await; - settle(&mut db, 3, 0, Some(100)).await; - fill(&mut db, order_a, 4).await; - settle(&mut db, 5, 1, Some(300)).await; - - let orders_in = async |db: &mut PgTransaction<'_>, tx: u8| { - let mut orders = full_orders_in_tx(db, &ByteArray([tx; 32])) - .map_ok(|order| (order.uid, order.gas_cost)) - .try_collect::>() - .await - .unwrap(); - // The query does not order its rows. - orders.sort_by_key(|(uid, _)| uid.0); - orders - }; - - // 333 + 333 for order_a's two fills here, plus 3000 from the fill the - // *other* transaction settled — repeated once per fill. - assert_eq!( - orders_in(&mut db, 0).await, - vec![ - (order_a, Some(3666.into())), - (order_a, Some(3666.into())), - (order_b, Some(333.into())), - ] - ); - - // One fill here, so one row, still carrying the other transaction's. - assert_eq!( - orders_in(&mut db, 1).await, - vec![(order_a, Some(3666.into()))] - ); - } - #[tokio::test] #[ignore] async fn postgres_latest_settlement_block() { diff --git a/crates/database/src/trades.rs b/crates/database/src/trades.rs index e145ed85e6..006a0120d7 100644 --- a/crates/database/src/trades.rs +++ b/crates/database/src/trades.rs @@ -26,26 +26,18 @@ pub struct TradesQueryRow { pub sell_token: Address, pub tx_hash: Option, pub auction_id: Option, - /// This trade's share of its settlement's gas cost in native token wei, as - /// attributed by [`attribute_gas_cost`]. `NULL` for settlements observed - /// before the migration that added the column, `0` for a JIT order that - /// only provided liquidity. + /// Share of the settlement's gas cost in native token wei, as attributed + /// by [`attribute_gas_cost`]. `NULL` for settlements observed before the + /// column existed, `0` for a liquidity-only JIT order. pub gas_cost: Option, } /// Select-list expression summing the gas costs of the fills of an order -/// aliased `o`. `NULL` unless every fill's cost is known — a bare `SUM` would -/// silently understate the total. Read through the alias `fill` so it also -/// works in a query that joins `trades` itself. -/// -/// Unlike the executed-amount sums beside it, this probe is not covered by the -/// `trades_covering` index, so it visits the heap: measured at +45% plan cost -/// for a 1000-order page. If that starts to show on the order endpoints, add -/// `gas_cost` to that index's `INCLUDE` list to make the probe index-only -/// again. -pub(crate) const ORDER_GAS_COST: &str = "(SELECT CASE WHEN COUNT(*) = COUNT(fill.gas_cost) THEN \ - SUM(fill.gas_cost) END FROM trades fill WHERE \ - fill.order_uid = o.uid) AS gas_cost"; +/// aliased `o`. `NULL` unless every fill's cost is known, because a bare `SUM` +/// would silently understate the total. +pub(crate) const ORDER_GAS_COST: &str = "(SELECT CASE WHEN COUNT(*) = COUNT(t.gas_cost) THEN \ + SUM(t.gas_cost) END FROM trades t WHERE t.order_uid = \ + o.uid) AS gas_cost"; pub fn trades<'a>( ex: &'a mut PgConnection, @@ -204,10 +196,9 @@ pub async fn get_trades_for_settlement( /// settlements attributes its full cost twice, once per settlement. We do not /// expect this to happen. /// -/// The `orders` test only holds once the indexer that writes an on-chain -/// order's row has caught up with the settlement's block, which is why the -/// autopilot attributes from `run_optional_maintenance`. Attributing earlier -/// would permanently class such an order as liquidity-only. +/// The `orders` test only holds once the indexer has written an on-chain +/// order's row, hence attribution from `run_optional_maintenance`: attributing +/// earlier would permanently class such an order as liquidity-only. #[instrument(skip_all)] pub async fn attribute_gas_cost( ex: &mut PgTransaction<'_>, @@ -1018,8 +1009,7 @@ mod tests { ); } - /// The trades query reports the share stored for each trade, and no cost - /// at all for a trade whose settlement was never attributed. + /// A trade whose settlement was never attributed reports no cost at all. #[tokio::test] #[ignore] async fn postgres_trades_report_attributed_gas_cost() { diff --git a/crates/model/src/order.rs b/crates/model/src/order.rs index 4aab1f9b4c..2d6718eda1 100644 --- a/crates/model/src/order.rs +++ b/crates/model/src/order.rs @@ -712,9 +712,8 @@ pub struct OrderMetadata { #[serde_as(as = "HexOrDecimalU256")] pub executed_fee: U256, pub executed_fee_token: Address, - /// The order's estimated share of its settlements' gas costs, in native - /// token wei, summed across its fills. `None` unless the cost of every - /// fill is known. + /// Share of its settlements' gas costs in native token wei, summed across + /// fills. `None` unless every fill's cost is known. #[serde_as(as = "Option")] #[serde(default, skip_serializing_if = "Option::is_none")] pub gas_cost: Option, diff --git a/crates/model/src/trade.rs b/crates/model/src/trade.rs index 3e40aa185c..6e1b133b57 100644 --- a/crates/model/src/trade.rs +++ b/crates/model/src/trade.rs @@ -31,10 +31,9 @@ pub struct Trade { // Settlement Data pub tx_hash: Option, pub executed_protocol_fees: Vec, - /// The trade's estimated share of its settlement's gas cost, in native - /// token wei. `None` if the settlement predates this being recorded, `0` - /// for a JIT order that only provided liquidity for the settlement's user - /// trades. + /// Share of the settlement's gas cost in native token wei. `None` if the + /// settlement predates this being recorded, `0` for a JIT order that only + /// provided liquidity. #[serde_as(as = "Option")] #[serde(default, skip_serializing_if = "Option::is_none")] pub gas_cost: Option, @@ -150,18 +149,6 @@ mod tests { assert_json_matches!(serialized, value); } - #[test] - fn unknown_gas_cost_is_omitted() { - let serialized = serde_json::to_value(Trade::default()).unwrap(); - assert!(serialized.get("gasCost").is_none()); - assert_eq!( - serde_json::from_value::(serialized) - .unwrap() - .gas_cost, - None - ); - } - #[test] fn debug_trade_data() { dbg!(Trade::default()); diff --git a/crates/orderbook/openapi.yml b/crates/orderbook/openapi.yml index 242e1ba478..3a3b8b4802 100644 --- a/crates/orderbook/openapi.yml +++ b/crates/orderbook/openapi.yml @@ -1410,10 +1410,8 @@ components: description: > Estimated gas cost attributed to this order, in native token wei, summed across its fills. Omitted unless the cost of every fill is - known, so an order that is not yet settled, or any of whose fills - predate this data being recorded, reports no cost rather than a - partial one. Only settlements observed after this field was - introduced carry a cost, so it is absent for most historical orders. + known, rather than reporting a partial total, so it is absent for an + unsettled order and for orders predating this being recorded. allOf: - $ref: "#/components/schemas/BigUint" fullAppData: @@ -1800,9 +1798,8 @@ components: Estimated gas cost attributed to this trade, in native token wei: the settlement's gas cost split equally between the user trades it settled, not weighted by the gas each one consumed. Omitted if the - settlement predates this being recorded, which is the case for most - historical trades, and `0` for a JIT order that only provided - liquidity for the settlement's user trades. + settlement predates this being recorded, and `0` for a JIT order + that only provided liquidity. allOf: - $ref: "#/components/schemas/BigUint" required: diff --git a/crates/orderbook/src/database/orders.rs b/crates/orderbook/src/database/orders.rs index 7be84125e5..2d0a495c52 100644 --- a/crates/orderbook/src/database/orders.rs +++ b/crates/orderbook/src/database/orders.rs @@ -685,7 +685,7 @@ fn is_buy_order_filled(amount: &BigDecimal, executed_amount: &BigDecimal) -> boo mod tests { use { super::*, - alloy::primitives::{Address, U256}, + alloy::primitives::Address, chrono::Duration, database::{ byte_array::ByteArray, @@ -707,9 +707,11 @@ mod tests { std::sync::atomic::{AtomicI64, Ordering}, }; - fn order_row() -> FullOrder { + #[test] + fn order_status() { let valid_to_timestamp = Utc::now() + Duration::days(1); - FullOrder { + + let order_row = || FullOrder { uid: ByteArray([0; 56]), owner: ByteArray([0; 20]), creation_timestamp: Utc::now(), @@ -744,35 +746,8 @@ mod tests { executed_fee_token: ByteArray([1; 20]), // TODO surplus token full_app_data: Default::default(), gas_cost: None, - } - } - - /// An unrepresentable cost is an error, not a silent absence that would - /// read as "never attributed". - #[test] - fn convert_order_gas_cost() { - let convert = |gas_cost| { - full_order_with_quote_into_model_order( - FullOrder { - gas_cost, - ..order_row() - }, - None, - ) }; - assert_eq!( - convert(Some(BigDecimal::from(1000))) - .unwrap() - .metadata - .gas_cost, - Some(U256::from(1000)) - ); - assert_eq!(convert(None).unwrap().metadata.gas_cost, None); - assert!(convert(Some(BigDecimal::from(-1))).is_err()); - } - #[test] - fn order_status() { // Open - sell (filled - 0%) assert_eq!(calculate_status(&order_row()), OrderStatus::Open); diff --git a/crates/orderbook/src/database/trades.rs b/crates/orderbook/src/database/trades.rs index 5bda4edc75..9ae5f12e84 100644 --- a/crates/orderbook/src/database/trades.rs +++ b/crates/orderbook/src/database/trades.rs @@ -199,33 +199,22 @@ mod tests { trade_from(TradesQueryRow::default(), vec![]).unwrap(); } - /// An unattributed cost is reported as absent rather than as zero. #[test] fn convert_trade_gas_cost() { - let row = TradesQueryRow { - gas_cost: Some(BigDecimal::from(1000)), - ..Default::default() + let convert = |gas_cost| { + trade_from( + TradesQueryRow { + gas_cost, + ..Default::default() + }, + vec![], + ) }; assert_eq!( - trade_from(row, vec![]).unwrap().gas_cost, + convert(Some(BigDecimal::from(1000))).unwrap().gas_cost, Some(U256::from(1000)) ); - assert_eq!( - trade_from(TradesQueryRow::default(), vec![]) - .unwrap() - .gas_cost, - None - ); - } - - /// An unrepresentable cost is an error, not a silent absence that would - /// read as "never attributed". - #[test] - fn convert_trade_rejects_unrepresentable_gas_cost() { - let row = TradesQueryRow { - gas_cost: Some(BigDecimal::from(-1)), - ..Default::default() - }; - assert!(trade_from(row, vec![]).is_err()); + // An unrepresentable cost errors instead of reading as unattributed. + assert!(convert(Some(BigDecimal::from(-1))).is_err()); } } From 486bbc7330a678b259a55727a5c0c0d579b38788 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Duarte?= <15343819+jmg-duarte@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:20:31 +0100 Subject: [PATCH 4/5] Simplify --- crates/database/src/jit_orders.rs | 9 ++++----- crates/database/src/orders.rs | 9 ++++----- crates/database/src/trades.rs | 7 ------- 3 files changed, 8 insertions(+), 17 deletions(-) diff --git a/crates/database/src/jit_orders.rs b/crates/database/src/jit_orders.rs index a41db10a96..65e9bb35da 100644 --- a/crates/database/src/jit_orders.rs +++ b/crates/database/src/jit_orders.rs @@ -17,8 +17,7 @@ use { tracing::instrument, }; -pub const SELECT: &str = const_format::concatcp!( - r#" +pub const SELECT: &str = r#" o.uid, o.owner, o.creation_timestamp, o.sell_token, o.buy_token, o.sell_amount, o.buy_amount, o.valid_to, NULL AS valid_from, o.app_data, o.fee_amount, o.kind, o.partially_fillable, o.signature, o.receiver, o.signing_scheme, '\x9008d19f58aabd9ed0d60971565aa8510560ab41'::bytea AS settlement_contract, o.sell_token_balance, o.buy_token_balance, @@ -35,9 +34,9 @@ NULL AS onchain_user, NULL AS onchain_placement_error, COALESCE((SELECT SUM(executed_fee) FROM order_execution oe WHERE oe.order_uid = o.uid), 0) as executed_fee, COALESCE((SELECT executed_fee_token FROM order_execution oe WHERE oe.order_uid = o.uid LIMIT 1), o.sell_token) as executed_fee_token, -- TODO surplus token -NULL AS full_app_data, "#, - crate::trades::ORDER_GAS_COST, -); +NULL AS full_app_data, +(SELECT CASE WHEN COUNT(*) = COUNT(t.gas_cost) THEN SUM(t.gas_cost) END FROM trades t WHERE t.order_uid = o.uid) as gas_cost +"#; pub const FROM: &str = "jit_orders o"; diff --git a/crates/database/src/orders.rs b/crates/database/src/orders.rs index 6aff0f1de6..4190898048 100644 --- a/crates/database/src/orders.rs +++ b/crates/database/src/orders.rs @@ -630,8 +630,7 @@ impl FullOrderWithQuote { // SET enable_nestloop = false; // to get a better idea of what indexes postgres *could* use even if it decides // that with the current amount of data this wouldn't be better. -pub const SELECT: &str = const_format::concatcp!( - r#" +pub const SELECT: &str = r#" o.uid, o.owner, o.creation_timestamp, o.sell_token, o.buy_token, o.sell_amount, o.buy_amount, o.valid_to, o.valid_from, o.app_data, o.fee_amount, o.kind, o.partially_fillable, o.signature, o.receiver, o.signing_scheme, o.settlement_contract, o.sell_token_balance, o.buy_token_balance, @@ -659,9 +658,9 @@ array(Select (p.target, p.value, p.data) from interactions p where p.order_uid = (SELECT onchain_o.placement_error from onchain_placed_orders onchain_o where onchain_o.uid = o.uid limit 1) as onchain_placement_error, COALESCE((SELECT SUM(executed_fee) FROM order_execution oe WHERE oe.order_uid = o.uid), 0) as executed_fee, COALESCE((SELECT executed_fee_token FROM order_execution oe WHERE oe.order_uid = o.uid LIMIT 1), o.sell_token) as executed_fee_token, -- TODO surplus token -(SELECT full_app_data FROM app_data ad WHERE o.app_data = ad.contract_app_data LIMIT 1) as full_app_data, "#, - crate::trades::ORDER_GAS_COST, -); +(SELECT full_app_data FROM app_data ad WHERE o.app_data = ad.contract_app_data LIMIT 1) as full_app_data, +(SELECT CASE WHEN COUNT(*) = COUNT(t.gas_cost) THEN SUM(t.gas_cost) END FROM trades t WHERE t.order_uid = o.uid) as gas_cost +"#; pub const FROM: &str = "orders o"; const FULL_ORDER_WITH_QUOTE: &str = const_format::concatcp!( diff --git a/crates/database/src/trades.rs b/crates/database/src/trades.rs index 006a0120d7..8bdad98ff2 100644 --- a/crates/database/src/trades.rs +++ b/crates/database/src/trades.rs @@ -32,13 +32,6 @@ pub struct TradesQueryRow { pub gas_cost: Option, } -/// Select-list expression summing the gas costs of the fills of an order -/// aliased `o`. `NULL` unless every fill's cost is known, because a bare `SUM` -/// would silently understate the total. -pub(crate) const ORDER_GAS_COST: &str = "(SELECT CASE WHEN COUNT(*) = COUNT(t.gas_cost) THEN \ - SUM(t.gas_cost) END FROM trades t WHERE t.order_uid = \ - o.uid) AS gas_cost"; - pub fn trades<'a>( ex: &'a mut PgConnection, owner_filter: Option<&'a Address>, From 56087cad18d32e20aefed151936939af9cef516f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Duarte?= <15343819+jmg-duarte@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:39:02 +0100 Subject: [PATCH 5/5] Comment cleanup --- crates/database/src/orders.rs | 2 +- crates/database/src/trades.rs | 9 ++++----- crates/model/src/order.rs | 2 +- crates/model/src/trade.rs | 6 +++--- crates/orderbook/openapi.yml | 12 +++++++----- 5 files changed, 16 insertions(+), 15 deletions(-) diff --git a/crates/database/src/orders.rs b/crates/database/src/orders.rs index dc7b15b8bb..7a3c881da1 100644 --- a/crates/database/src/orders.rs +++ b/crates/database/src/orders.rs @@ -539,7 +539,7 @@ pub struct FullOrder { pub executed_fee_token: Address, pub full_app_data: Option>, /// Share of its settlements' gas costs in native token wei, summed across - /// fills. `None` when any fill's cost is unknown. + /// fills. `None` unless it has fills and every fill's cost is known. pub gas_cost: Option, } diff --git a/crates/database/src/trades.rs b/crates/database/src/trades.rs index 78289cc587..3f9a4d390f 100644 --- a/crates/database/src/trades.rs +++ b/crates/database/src/trades.rs @@ -27,8 +27,8 @@ pub struct TradesQueryRow { pub tx_hash: Option, pub auction_id: Option, /// Share of the settlement's gas cost in native token wei, as attributed - /// by [`attribute_gas_cost`]. `NULL` for settlements observed before the - /// column existed, `0` for a liquidity-only JIT order. + /// by [`attribute_gas_cost`]: `NULL` until then, forever for settlements + /// observed before the column existed. `0` for a liquidity-only JIT order. pub gas_cost: Option, } @@ -189,9 +189,8 @@ pub async fn get_trades_for_settlement( /// settlements attributes its full cost twice, once per settlement. We do not /// expect this to happen. /// -/// The `orders` test only holds once the indexer has written an on-chain -/// order's row, hence attribution from `run_optional_maintenance`: attributing -/// earlier would permanently class such an order as liquidity-only. +/// Must run after the settled orders' rows exist: an on-chain order whose row +/// is not indexed yet is permanently classed as liquidity-only. #[instrument(skip_all)] pub async fn attribute_gas_cost( ex: &mut PgTransaction<'_>, diff --git a/crates/model/src/order.rs b/crates/model/src/order.rs index a5be9e10e6..343b22e9b6 100644 --- a/crates/model/src/order.rs +++ b/crates/model/src/order.rs @@ -714,7 +714,7 @@ pub struct OrderMetadata { pub executed_fee: U256, pub executed_fee_token: Address, /// Share of its settlements' gas costs in native token wei, summed across - /// fills. `None` unless every fill's cost is known. + /// fills. `None` unless it has fills and every fill's cost is known. #[serde_as(as = "Option")] #[serde(default, skip_serializing_if = "Option::is_none")] pub gas_cost: Option, diff --git a/crates/model/src/trade.rs b/crates/model/src/trade.rs index 6e1b133b57..5b9a992d97 100644 --- a/crates/model/src/trade.rs +++ b/crates/model/src/trade.rs @@ -31,9 +31,9 @@ pub struct Trade { // Settlement Data pub tx_hash: Option, pub executed_protocol_fees: Vec, - /// Share of the settlement's gas cost in native token wei. `None` if the - /// settlement predates this being recorded, `0` for a JIT order that only - /// provided liquidity. + /// Share of the settlement's gas cost in native token wei. `None` until + /// the settlement is attributed, which never happens if it predates this + /// being recorded; `0` for a JIT order that only provided liquidity. #[serde_as(as = "Option")] #[serde(default, skip_serializing_if = "Option::is_none")] pub gas_cost: Option, diff --git a/crates/orderbook/openapi.yml b/crates/orderbook/openapi.yml index 5cd34124a2..c85a085d3f 100644 --- a/crates/orderbook/openapi.yml +++ b/crates/orderbook/openapi.yml @@ -1410,8 +1410,9 @@ components: description: > Estimated gas cost attributed to this order, in native token wei, summed across its fills. Omitted unless the cost of every fill is - known, rather than reporting a partial total, so it is absent for an - unsettled order and for orders predating this being recorded. + known, rather than reporting a partial total: absent for an unsettled + order, until the latest fill's settlement has been attributed, and + for orders with a fill predating this being recorded. allOf: - $ref: "#/components/schemas/BigUint" fullAppData: @@ -1803,9 +1804,10 @@ components: description: > Estimated gas cost attributed to this trade, in native token wei: the settlement's gas cost split equally between the user trades it - settled, not weighted by the gas each one consumed. Omitted if the - settlement predates this being recorded, and `0` for a JIT order - that only provided liquidity. + settled, not weighted by the gas each one consumed. Omitted until + the settlement has been attributed, shortly after it is indexed, + and permanently if it predates this being recorded. `0` for a JIT + order that only provided liquidity. allOf: - $ref: "#/components/schemas/BigUint" required: