From 7bfa6fb7ec1519fd9fe87a16ba3a2899b68111c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Duarte?= <15343819+jmg-duarte@users.noreply.github.com> Date: Thu, 18 Jun 2026 10:40:26 +0100 Subject: [PATCH 1/2] Add on-chain gas cost to trades API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Persist the actual settlement gas (gas_used, effective_gas_price) read from the transaction receipt — previously computed by the autopilot but only logged since the settlement_observations table was dropped in V090 — and expose a per-trade `gasCost` (native token wei). Attribution: each trade gets its share of the settlement transaction's gas cost (gas_used * effective_gas_price), split equally across all trades settled in the same transaction. - V111 migration: nullable gas_used + effective_gas_price on settlements - autopilot save_settlement writes them (going-forward only, no backfill) - trades query computes per-trade gas_cost in SQL - model + openapi documentation for the new gasCost field on trades Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/autopilot/src/infra/persistence/mod.rs | 9 +++ crates/database/src/settlements.rs | 26 ++++++ crates/database/src/trades.rs | 79 ++++++++++++++++--- crates/model/src/trade.rs | 11 ++- crates/orderbook/openapi.yml | 10 +++ crates/orderbook/src/database/trades.rs | 4 +- database/sql/V111__add_gas_to_settlements.sql | 13 +++ 7 files changed, 138 insertions(+), 14 deletions(-) create mode 100644 database/sql/V111__add_gas_to_settlements.sql diff --git a/crates/autopilot/src/infra/persistence/mod.rs b/crates/autopilot/src/infra/persistence/mod.rs index f82491537a..bb8e906671 100644 --- a/crates/autopilot/src/infra/persistence/mod.rs +++ b/crates/autopilot/src/infra/persistence/mod.rs @@ -817,6 +817,15 @@ impl Persistence { ) .await?; + database::settlements::update_settlement_gas( + &mut ex, + block_number, + log_index, + u256_to_big_decimal(&gas.0), + u256_to_big_decimal(&gas_price.0.0), + ) + .await?; + store_order_events( &mut ex, fee_breakdown.keys().cloned().collect(), diff --git a/crates/database/src/settlements.rs b/crates/database/src/settlements.rs index c47b378e81..8f98a1923d 100644 --- a/crates/database/src/settlements.rs +++ b/crates/database/src/settlements.rs @@ -1,5 +1,6 @@ use { crate::{Address, PgTransaction, TransactionHash}, + bigdecimal::BigDecimal, sqlx::{Executor, PgConnection}, tracing::instrument, }; @@ -89,6 +90,31 @@ WHERE block_number = $3 AND log_index = $4 .map(|_| ()) } +/// Stores the actual gas cost (read from the transaction receipt) of a settled +/// transaction. See migration `V111`. +#[instrument(skip_all)] +pub async fn update_settlement_gas( + ex: &mut PgConnection, + block_number: i64, + log_index: i64, + gas_used: BigDecimal, + effective_gas_price: BigDecimal, +) -> Result<(), sqlx::Error> { + const QUERY: &str = r#" +UPDATE settlements +SET gas_used = $1, effective_gas_price = $2 +WHERE block_number = $3 AND log_index = $4 + ;"#; + sqlx::query(QUERY) + .bind(gas_used) + .bind(effective_gas_price) + .bind(block_number) + .bind(log_index) + .execute(ex) + .await?; + Ok(()) +} + /// Deletes all database data that referenced the deleted settlement events. #[instrument(skip_all)] pub async fn delete( diff --git a/crates/database/src/trades.rs b/crates/database/src/trades.rs index 09d6072ae5..cd178d438e 100644 --- a/crates/database/src/trades.rs +++ b/crates/database/src/trades.rs @@ -18,8 +18,34 @@ pub struct TradesQueryRow { pub sell_token: Address, pub tx_hash: Option, pub auction_id: Option, + /// This trade's share of the settlement transaction's gas cost in native + /// token wei (`gas_used * effective_gas_price / trades_in_settlement`). + /// `NULL` for settlements observed before gas was persisted (see V111). + pub gas_cost: Option, } +/// SQL expression computing a single trade's share of its settlement's on-chain +/// gas cost: the settlement's total cost (`gas_used * effective_gas_price`) +/// divided equally across all trades settled in the same transaction. Expects +/// the settlement row to be aliased `s`; selected as the column `gas_cost`, +/// which is `NULL` for settlements whose gas was not persisted (see migration +/// V111). Shared by [`trades`] and [`order_gas_costs`] so the two cannot drift. +const GAS_COST_EXPR: &str = r#"FLOOR( + (s.gas_used * s.effective_gas_price) + / NULLIF(( + SELECT COUNT(*) + FROM trades tc + WHERE tc.block_number = s.block_number + AND tc.log_index < s.log_index + AND tc.log_index > COALESCE(( + SELECT MAX(sp.log_index) + FROM settlements sp + WHERE sp.block_number = s.block_number + AND sp.log_index < s.log_index + ), -1) + ), 0) + ) AS gas_cost"#; + pub fn trades<'a>( ex: &'a mut PgConnection, owner_filter: Option<&'a Address>, @@ -37,24 +63,37 @@ SELECT t.sell_amount - t.fee_amount as sell_amount_before_fees, o.owner, o.buy_token, - o.sell_token, - settlement.tx_hash, - settlement.auction_id"#; - - const SETTLEMENT_JOIN: &str = r#" + o.sell_token"#; + + // Resolves the settlement that included each returned trade (the first + // settlement event in the same block after the trade) and computes that + // trade's share of the settlement's on-chain gas cost. Joined onto the + // already-paginated `page` CTE rather than inside the UNION branches, so the + // settlement lookup and the (relatively expensive) gas computation run only + // for the rows actually returned instead of for every candidate row across + // all three branches. + const GAS_JOIN: &str = const_format::concatcp!( + r#" LEFT OUTER JOIN LATERAL ( - SELECT tx_hash, auction_id FROM settlements s - WHERE s.block_number = t.block_number - AND s.log_index > t.log_index + SELECT + s.tx_hash, + s.auction_id, + "#, + GAS_COST_EXPR, + r#" + FROM settlements s + WHERE s.block_number = page.block_number + AND s.log_index > page.log_index ORDER BY s.log_index ASC LIMIT 1 -) AS settlement ON true"#; +) AS settlement ON true"#, + ); const QUERY: &str = const_format::concatcp!( + "WITH page AS (", "(", SELECT, " FROM trades t", - SETTLEMENT_JOIN, " JOIN orders o ON o.uid = t.order_uid", // the uid already contains the owner address and we have // an index on this expression so this is very efficient @@ -67,7 +106,6 @@ LEFT OUTER JOIN LATERAL ( "(", SELECT, " FROM trades t", - SETTLEMENT_JOIN, " JOIN orders o ON o.uid = t.order_uid", " JOIN onchain_placed_orders onchain_o", " ON onchain_o.uid = t.order_uid", @@ -101,13 +139,30 @@ LEFT OUTER JOIN LATERAL ( SELECT, " FROM jit o", " JOIN trades t ON o.uid = t.order_uid", - SETTLEMENT_JOIN, " ORDER BY t.block_number DESC, t.log_index DESC", " LIMIT $3 + $4", ")", " ORDER BY block_number DESC, log_index DESC", " LIMIT $3", " OFFSET $4", + ")", + r#" +SELECT + page.block_number, + page.log_index, + page.order_uid, + page.buy_amount, + page.sell_amount, + page.sell_amount_before_fees, + page.owner, + page.buy_token, + page.sell_token, + settlement.tx_hash, + settlement.auction_id, + settlement.gas_cost +FROM page"#, + GAS_JOIN, + " ORDER BY page.block_number DESC, page.log_index DESC", ); sqlx::query_as(QUERY) diff --git a/crates/model/src/trade.rs b/crates/model/src/trade.rs index 586c2afa74..90e66ddc4d 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, + /// Estimated network gas cost (in native token wei) of executing this + /// trade, derived from the order's quote (`gas_amount * gas_price`). + /// `None` when the order has no stored quote (e.g. JIT orders). + #[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 4e07465519..f3d256f833 100644 --- a/crates/orderbook/openapi.yml +++ b/crates/orderbook/openapi.yml @@ -1706,6 +1706,16 @@ components: type: array items: $ref: "#/components/schemas/ExecutedProtocolFee" + gasCost: + description: > + Actual on-chain gas cost attributed to this trade, in native token + wei. Computed as this trade's share of the settlement transaction's + gas cost (`gas_used * effective_gas_price`), split equally across all + trades settled in the same transaction. `null` for trades settled + before this data started being recorded. + 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 0b2bf5bd08..22ce176d0d 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,7 @@ 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().and_then(big_decimal_to_u256); Ok(Trade { block_number, log_index, @@ -181,6 +182,7 @@ fn trade_from( sell_token, tx_hash, executed_protocol_fees, + gas_cost, }) } diff --git a/database/sql/V111__add_gas_to_settlements.sql b/database/sql/V111__add_gas_to_settlements.sql new file mode 100644 index 0000000000..4125cbf804 --- /dev/null +++ b/database/sql/V111__add_gas_to_settlements.sql @@ -0,0 +1,13 @@ +-- Store the actual on-chain gas cost of each settlement transaction. +-- +-- These values are read from the transaction receipt by the autopilot's +-- settlement observer (gas_used and effective_gas_price are computed there but, +-- since the `settlement_observations` table was dropped in V090, no longer +-- persisted). Re-introducing them here lets the orderbook attribute a real gas +-- cost to individual trades and orders. +-- +-- Nullable: only populated for settlements observed after this migration is +-- deployed (no historical backfill). +ALTER TABLE settlements + ADD COLUMN gas_used numeric(78, 0), + ADD COLUMN effective_gas_price numeric(78, 0); From 8e6bae8586dea68eb6c64eb14dfb76e8e27a6eb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Duarte?= <15343819+jmg-duarte@users.noreply.github.com> Date: Thu, 18 Jun 2026 10:40:56 +0100 Subject: [PATCH 2/2] Add on-chain gas cost to orders API Expose a per-order `gasCost` (native token wei): the sum, across the order's fills, of each trade's share of its settlement transaction's gas cost (gas_used * effective_gas_price), split equally across all trades settled in the same transaction. Builds on the settlement gas now persisted for trades. - order_gas_costs aggregates the per-trade gas attribution per order_uid - orders wired on the single_order / many_orders read paths - model + openapi documentation for the new gasCost field on orders Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/database/src/trades.rs | 35 ++++++++++ crates/model/src/order.rs | 6 ++ crates/orderbook/openapi.yml | 11 +++ crates/orderbook/src/database/orders.rs | 83 +++++++++++++++++------ crates/shared/src/db_order_conversions.rs | 1 + 5 files changed, 115 insertions(+), 21 deletions(-) diff --git a/crates/database/src/trades.rs b/crates/database/src/trades.rs index cd178d438e..9c30607bfa 100644 --- a/crates/database/src/trades.rs +++ b/crates/database/src/trades.rs @@ -174,6 +174,41 @@ FROM page"#, .instrument(info_span!("trades")) } +/// Returns, per order, the total on-chain gas cost (native token wei) +/// attributed to the order across all of its trades. Each trade contributes its +/// share of its settlement's gas cost (see [`GAS_COST_EXPR`]). Orders whose +/// settlements have no persisted gas (see V111) are absent from the result. +#[instrument(skip_all)] +pub async fn order_gas_costs( + ex: &mut PgConnection, + order_uids: &[OrderUid], +) -> Result, sqlx::Error> { + if order_uids.is_empty() { + return Ok(vec![]); + } + + const QUERY: &str = const_format::concatcp!( + r#" +SELECT t.order_uid, FLOOR(SUM(settlement.gas_cost)) AS gas_cost +FROM trades t +JOIN LATERAL ( + SELECT "#, + GAS_COST_EXPR, + r#" + FROM settlements s + WHERE s.block_number = t.block_number + AND s.log_index > t.log_index + ORDER BY s.log_index ASC + LIMIT 1 +) AS settlement ON true +WHERE settlement.gas_cost IS NOT NULL +AND t.order_uid = ANY($1) +GROUP BY t.order_uid"#, + ); + + sqlx::query_as(QUERY).bind(order_uids).fetch_all(ex).await +} + #[derive(Clone, Debug, Default, Eq, PartialEq, sqlx::FromRow)] pub struct TradeEvent { pub block_number: i64, diff --git a/crates/model/src/order.rs b/crates/model/src/order.rs index 5243118a99..d7bd7e0eba 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, + /// Estimated network gas cost (in native token wei) of executing this + /// order, derived from its quote (`gas_amount * gas_price`). `None` when + /// the order has no stored quote. + #[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/orderbook/openapi.yml b/crates/orderbook/openapi.yml index f3d256f833..01826ccec8 100644 --- a/crates/orderbook/openapi.yml +++ b/crates/orderbook/openapi.yml @@ -1336,6 +1336,17 @@ components: allOf: - $ref: "#/components/schemas/Address" nullable: false + gasCost: + description: > + Actual on-chain gas cost attributed to this order, in native token + wei. Computed as the order's share of the gas cost + (`gas_used * effective_gas_price`) of the settlement transaction(s) + that executed it, split equally across all trades settled in the same + transaction, and summed across the order's fills. `null` for orders + settled before this data started being recorded, or not yet settled. + allOf: + - $ref: "#/components/schemas/BigUint" + nullable: true fullAppData: description: > Full `appData`, which the contract-level `appData` is a hash of. See diff --git a/crates/orderbook/src/database/orders.rs b/crates/orderbook/src/database/orders.rs index 081336e3ed..90f56e4ec1 100644 --- a/crates/orderbook/src/database/orders.rs +++ b/crates/orderbook/src/database/orders.rs @@ -50,7 +50,7 @@ use { order_validation::{Amounts, LimitOrderCounting, is_order_outside_market_price}, }, sqlx::{Connection, PgConnection, types::BigDecimal}, - std::convert::TryInto, + std::{collections::HashMap, convert::TryInto}, }; #[cfg_attr(test, mockall::automock)] @@ -297,22 +297,29 @@ impl OrderStoring for Postgres { let mut ex = self.pool.acquire().await?; - match orders::single_full_order_with_quote(&mut ex, &ByteArray(uid.0)).await? { - Some(order_with_quote) => { - let (order, quote) = order_with_quote.into_order_and_quote(); - Some(full_order_with_quote_into_model_order( - order, - quote.as_ref(), - )) - } - None => { - // try to find the order in the JIT orders table - database::jit_orders::get_by_id(&mut ex, &ByteArray(uid.0)) - .await? - .map(full_order_into_model_order) + let mut order = + match orders::single_full_order_with_quote(&mut ex, &ByteArray(uid.0)).await? { + Some(order_with_quote) => { + let (order, quote) = order_with_quote.into_order_and_quote(); + Some(full_order_with_quote_into_model_order( + order, + quote.as_ref(), + )) + } + None => { + // try to find the order in the JIT orders table + database::jit_orders::get_by_id(&mut ex, &ByteArray(uid.0)) + .await? + .map(full_order_into_model_order) + } } + .transpose()?; + + if let Some(order) = order.as_mut() { + order.metadata.gas_cost = attributed_gas_cost(&mut ex, uid).await?; } - .transpose() + + Ok(order) } async fn many_orders(&self, uids: &[OrderUid]) -> Result)>> { @@ -336,13 +343,25 @@ impl OrderStoring for Postgres { // time database::jit_orders::get_many_by_uid(&mut ex_jit_orders, &uids) )?; + + let gas_costs = database::trades::order_gas_costs(&mut ex_orders, &uids) + .await? + .into_iter() + .filter_map(|(uid, gas_cost)| big_decimal_to_u256(&gas_cost).map(|cost| (uid, cost))) + .collect::>(); + let gas_cost = |uid: &OrderUid| gas_costs.get(&ByteArray(uid.0)).copied(); + Ok(orders .into_iter() .map(|order| { let (order, quote) = order.into_order_and_quote(); let uid = OrderUid(order.uid.0); - let result = - full_order_with_quote_into_model_order(order, quote.as_ref()).map_err(|err| { + let result = full_order_with_quote_into_model_order(order, quote.as_ref()) + .map(|mut order| { + order.metadata.gas_cost = gas_cost(&uid); + order + }) + .map_err(|err| { tracing::warn!(?err, "Error converting into model order"); err.context("Error converting into model order") }); @@ -350,10 +369,15 @@ impl OrderStoring for Postgres { }) .chain(jit_orders.into_iter().map(|order| { let uid = OrderUid(order.uid.0); - let result = full_order_into_model_order(order).map_err(|err| { - tracing::warn!(?err, "Error converting Jit order into model order"); - err.context("Error converting Jit order into model order") - }); + let result = full_order_into_model_order(order) + .map(|mut order| { + order.metadata.gas_cost = gas_cost(&uid); + order + }) + .map_err(|err| { + tracing::warn!(?err, "Error converting Jit order into model order"); + err.context("Error converting Jit order into model order") + }); (uid, result) })) .collect()) @@ -562,6 +586,19 @@ fn full_order_into_model_order(order: FullOrder) -> Result { full_order_with_quote_into_model_order(order, None) } +/// Total on-chain gas cost (native token wei) attributed to a single order +/// across all of its trades. See [`database::trades::order_gas_costs`]. +async fn attributed_gas_cost( + ex: &mut PgConnection, + uid: &OrderUid, +) -> Result> { + Ok(database::trades::order_gas_costs(ex, &[ByteArray(uid.0)]) + .await? + .into_iter() + .next() + .and_then(|(_, gas_cost)| big_decimal_to_u256(&gas_cost))) +} + /// If quote is provided, then it is used to extract quote metadata field value. fn full_order_with_quote_into_model_order( order: FullOrder, @@ -609,6 +646,10 @@ 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), + // Filled in by the caller (see `single_order` / `many_orders`) from the + // on-chain settlement gas cost; left unset on read paths that don't + // attribute gas. + gas_cost: None, invalidated: order.invalidated, status, is_liquidity_order: class == OrderClass::Liquidity, diff --git a/crates/shared/src/db_order_conversions.rs b/crates/shared/src/db_order_conversions.rs index 96e90bdf6d..f989b16b8a 100644 --- a/crates/shared/src/db_order_conversions.rs +++ b/crates/shared/src/db_order_conversions.rs @@ -86,6 +86,7 @@ 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: None, invalidated: order.invalidated, status, is_liquidity_order: class == OrderClass::Liquidity,