diff --git a/crates/database/src/jit_orders.rs b/crates/database/src/jit_orders.rs index b87601686c..65e9bb35da 100644 --- a/crates/database/src/jit_orders.rs +++ b/crates/database/src/jit_orders.rs @@ -34,7 +34,8 @@ 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, +(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/order_history.rs b/crates/database/src/order_history.rs index 7efed87522..c6ea9b8518 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); @@ -360,5 +360,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 union keeps the `orders` row. + (uid_b, Some(BigDecimal::from(500))), + // 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 b3ee21f7d9..7a3c881da1 100644 --- a/crates/database/src/orders.rs +++ b/crates/database/src/orders.rs @@ -538,6 +538,9 @@ pub struct FullOrder { pub executed_fee: BigDecimal, pub executed_fee_token: Address, pub full_app_data: Option>, + /// Share of its settlements' gas costs in native token wei, summed across + /// fills. `None` unless it has fills and every fill's cost is known. + pub gas_cost: Option, } impl FullOrder { @@ -655,7 +658,8 @@ 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, +(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"; @@ -828,7 +832,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::numeric AS gas_cost FROM live_orders lo LEFT JOIN LATERAL ( SELECT NOT signed AS unsigned @@ -958,7 +963,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::numeric AS gas_cost FROM selected_orders so LEFT JOIN LATERAL ( SELECT NOT signed AS unsigned @@ -1196,6 +1202,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] @@ -2365,6 +2373,122 @@ 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(); + } + + /// 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, + &[( + 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(); + } + } + + 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 is unattributed. + #[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 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; + 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())); + } + #[tokio::test] #[ignore] async fn postgres_latest_settlement_block() { diff --git a/crates/database/src/trades.rs b/crates/database/src/trades.rs index 69bb86a08e..3f9a4d390f 100644 --- a/crates/database/src/trades.rs +++ b/crates/database/src/trades.rs @@ -26,6 +26,10 @@ pub struct TradesQueryRow { pub sell_token: Address, 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` until then, forever for settlements + /// observed before the column existed. `0` for a liquidity-only JIT order. + pub gas_cost: Option, } pub fn trades<'a>( @@ -46,6 +50,7 @@ SELECT o.owner, o.buy_token, o.sell_token, + t.gas_cost, settlement.tx_hash, settlement.auction_id"#; @@ -183,6 +188,9 @@ 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. +/// +/// 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<'_>, @@ -995,6 +1003,54 @@ mod tests { ); } + /// A trade whose settlement was never attributed reports no cost at all. + #[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 caa570345a..343b22e9b6 100644 --- a/crates/model/src/order.rs +++ b/crates/model/src/order.rs @@ -713,6 +713,11 @@ pub struct OrderMetadata { #[serde_as(as = "HexOrDecimalU256")] pub executed_fee: U256, pub executed_fee_token: Address, + /// Share of its settlements' gas costs in native token wei, summed across + /// 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, pub invalidated: bool, pub status: OrderStatus, #[serde(flatten)] diff --git a/crates/model/src/trade.rs b/crates/model/src/trade.rs index 586c2afa74..5b9a992d97 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,12 @@ 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` 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, } #[cfg(test)] @@ -56,6 +63,7 @@ mod tests { "sellToken": "0x000000000000000000000000000000000000000a", "buyToken": "0x0000000000000000000000000000000000000009", "txHash": "0x0000000000000000000000000000000000000000000000000000000000000040", + "gasCost": "3000000", "executedProtocolFees": [ { "amount": "5", @@ -104,6 +112,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), diff --git a/crates/orderbook/openapi.yml b/crates/orderbook/openapi.yml index 06f7be972d..c85a085d3f 100644 --- a/crates/orderbook/openapi.yml +++ b/crates/orderbook/openapi.yml @@ -1406,6 +1406,15 @@ 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, 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: description: > Full `appData`, which the contract-level `appData` is a hash of. See @@ -1791,6 +1800,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 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: - blockNumber - logIndex diff --git a/crates/orderbook/src/database/orders.rs b/crates/orderbook/src/database/orders.rs index 5bf1ce2e6d..9309be8f57 100644 --- a/crates/orderbook/src/database/orders.rs +++ b/crates/orderbook/src/database/orders.rs @@ -610,6 +610,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, @@ -739,6 +744,7 @@ mod tests { executed_fee: Default::default(), executed_fee_token: ByteArray([1; 20]), // TODO surplus token full_app_data: Default::default(), + gas_cost: None, }; // Open - sell (filled - 0%) diff --git a/crates/orderbook/src/database/trades.rs b/crates/orderbook/src/database/trades.rs index 0066baf1a1..0fb7fdff25 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, }; @@ -172,6 +172,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, @@ -184,15 +189,35 @@ 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(); } + + #[test] + fn convert_trade_gas_cost() { + let convert = |gas_cost| { + trade_from( + TradesQueryRow { + gas_cost, + ..Default::default() + }, + vec![], + ) + }; + assert_eq!( + convert(Some(BigDecimal::from(1000))).unwrap().gas_cost, + Some(U256::from(1000)) + ); + // An unrepresentable cost errors instead of reading as unattributed. + assert!(convert(Some(BigDecimal::from(-1))).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,