diff --git a/docs/src/prices.md b/docs/src/prices.md index 899c62b..9b79331 100644 --- a/docs/src/prices.md +++ b/docs/src/prices.md @@ -7,10 +7,11 @@ the market price is the midpoint of the highest bid and lowest ask: midpoint = (bestBid + bestAsk) / 2 ``` -A sample is stored only when both sides are positive and the book is not -crossed. When the market is closed or one side is unavailable, the most recent -valid sample remains available as a cached price. Samples older than seven days -are deleted. +A midpoint sample is stored only when both sides are positive and the book is +not crossed. Executable bids and asks are observed independently; an empty +output vault contributes no executable price on that side. When the market is +closed or one side is unavailable, the most recent valid sample remains +available as a cached price. Samples older than seven days are deleted. Price markets are discovered from the active registry. For every configured network containing ST0x tokens, the token list must identify exactly one quote @@ -22,9 +23,13 @@ addresses separately in the service. The asset unit is the canonical wrapped ST0x share returned in `assetAddress`. Orders using the underlying asset are converted with the current share's ERC-4626 assets-per-share ratio. Legacy wrapped shares are converted through -their own ratio into the current canonical share denomination before all orders -are combined. All decimal values are returned as strings so clients can choose -their required precision. +their own ratio into the current canonical share denomination before orders are +combined for display midpoints. The independent `executableBook` includes only +orders whose asset token is the canonical wrapped address, since swap requests +match exact token addresses and do not automatically wrap or migrate underlying +or legacy assets. Variant-only liquidity can therefore contribute a display +midpoint while the canonical executable book remains empty. All decimal values +are returned as strings so clients can choose their required precision. ## Latest Prices @@ -57,7 +62,13 @@ standalone registry networks are still included in this response, but remain "midpoint": "123.5", "source": "live", "observedAt": 1784800000, - "change24hPercent": "1.42" + "change24hPercent": "1.42", + "executableBook": { + "bestBid": "123.4", + "bestAsk": "123.6", + "observedAt": 1784800000, + "source": "live" + } } ] } @@ -70,6 +81,31 @@ standalone registry networks are still included in this response, but remain - `cached` when a retained observation exists but has not refreshed. - `unavailable` when no retained observation exists. +`executableBook` is independent of the retained midpoint fields. Its bid and ask +are nullable, and either side can refresh while the other side has no funded +executable orders. Each successful complete sample replaces both sides, clearing +an earlier side when it disappears. Crossed books publish both sides as null. +The object is null before the first sample, after retention expiry, or until a +new sample after the canonical token changes. It persists across server +restarts. Its `source` is `live` only for a non-future observation no more than +two sample intervals old; otherwise it is `cached`. A live object with two null +sides means no executable side was observed, not a tradable price. + +Trading clients should use a fresh positive `executableBook.bestAsk` for a buy +reference and the reciprocal of fresh positive `executableBook.bestBid` for a +sell reference (the API swap IO ratio is input tokens per output token). Both +book prices are quote units per canonical wrapped share; the sell reference is +the reciprocal bid, in wrapped shares per quote unit. Submit these references as +`referenceIoRatio` with `denomination: "wrapped"`. For +`denomination: "unwrapped"`, convert the reference first: +`unwrappedReference = wrappedReference * inputAssetsPerShare / outputAssetsPerShare` +(use 1 for a token without a wrapper). This accounts for each market's actual +spread; the display midpoint is not a directional trade price. Do not use +cached, future-dated, or missing sides as trading references. A book price does +not guarantee a requested trade size can fill, and a directional reference +protects against worsening execution rather than independently validating fair +market value. Normal quote limits and execution slippage still apply. + `change24hPercent` is null until a sample at least 24 hours older exists. ## Prices At A Timestamp @@ -82,8 +118,9 @@ GET /v2/prices?chainId=8453&at=1784800000 ``` Returns the nearest retained observation at or before `at` for every token. -Returned observations use `source: "historical"`. The API does not retrieve or -regenerate prices older than its retention window. +Returned observations use `source: "historical"` and `executableBook: null`. The +latest executable book is deliberately excluded from historical responses. The +API does not retrieve or regenerate prices older than its retention window. ## Token Price History diff --git a/migrations/20261006000000_create_market_books.sql b/migrations/20261006000000_create_market_books.sql new file mode 100644 index 0000000..3c50912 --- /dev/null +++ b/migrations/20261006000000_create_market_books.sql @@ -0,0 +1,10 @@ +CREATE TABLE market_books ( + chain_id INTEGER NOT NULL, + asset_token_address TEXT NOT NULL, + quote_token_address TEXT NOT NULL, + best_bid TEXT, + best_ask TEXT, + assets_per_share TEXT NOT NULL, + observed_at INTEGER NOT NULL, + PRIMARY KEY (chain_id, asset_token_address, quote_token_address) +); diff --git a/src/db/market_price_history.rs b/src/db/market_price_history.rs index 97bd680..1bf971b 100644 --- a/src/db/market_price_history.rs +++ b/src/db/market_price_history.rs @@ -13,6 +13,28 @@ pub(crate) struct NewMarketPriceSnapshot { pub observed_at: i64, } +/// Latest complete executable-book observation; absent sides clear earlier liquidity. +#[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)] +pub(crate) struct NewMarketBookSnapshot { + pub chain_id: i64, + pub asset_token_address: String, + pub quote_token_address: String, + pub best_bid: Option, + pub best_ask: Option, + pub assets_per_share: String, + pub observed_at: i64, +} + +pub(crate) async fn list_market_books( + pool: &DbPool, + chain_id: i64, + quote_token_address: &str, + retained_start: i64, +) -> Result, sqlx::Error> { + sqlx::query_as("SELECT chain_id, asset_token_address, quote_token_address, best_bid, best_ask, assets_per_share, observed_at FROM market_books WHERE chain_id = ? AND quote_token_address = ? AND observed_at >= ?") + .bind(chain_id).bind(quote_token_address).bind(retained_start).fetch_all(pool).await +} + #[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)] pub(crate) struct MarketPriceSnapshot { pub chain_id: i64, @@ -81,10 +103,20 @@ pub(crate) async fn insert_market_price_snapshots( Ok(rows_affected) } +#[cfg(test)] pub(crate) async fn replace_market_price_sample( pool: &DbPool, observed_at: i64, snapshots: &[NewMarketPriceSnapshot], +) -> Result<(u64, u64), sqlx::Error> { + replace_market_price_sample_with_books(pool, observed_at, snapshots, &[]).await +} + +pub(crate) async fn replace_market_price_sample_with_books( + pool: &DbPool, + observed_at: i64, + snapshots: &[NewMarketPriceSnapshot], + books: &[NewMarketBookSnapshot], ) -> Result<(u64, u64), sqlx::Error> { let mut tx = pool.begin().await?; let deleted = sqlx::query("DELETE FROM market_price_snapshots WHERE observed_at = ?") @@ -93,7 +125,24 @@ pub(crate) async fn replace_market_price_sample( .await? .rows_affected(); let inserted = insert_market_price_snapshots_in_transaction(&mut tx, snapshots).await?; + let mut skipped_books = 0; + for book in books { + let result = sqlx::query("INSERT INTO market_books (chain_id, asset_token_address, quote_token_address, best_bid, best_ask, assets_per_share, observed_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (chain_id, asset_token_address, quote_token_address) DO UPDATE SET best_bid = excluded.best_bid, best_ask = excluded.best_ask, assets_per_share = excluded.assets_per_share, observed_at = excluded.observed_at WHERE excluded.observed_at >= market_books.observed_at") + .bind(book.chain_id).bind(&book.asset_token_address).bind(&book.quote_token_address) + .bind(&book.best_bid).bind(&book.best_ask).bind(&book.assets_per_share).bind(book.observed_at) + .execute(&mut *tx).await?; + skipped_books += usize::from(result.rows_affected() == 0); + } tx.commit().await?; + if skipped_books > 0 { + tracing::warn!( + observed_at, + produced_book_count = books.len(), + written_book_count = books.len() - skipped_books, + skipped_book_count = skipped_books, + "older executable book observations were skipped to preserve newer observations" + ); + } Ok((deleted, inserted)) } @@ -178,6 +227,10 @@ pub(crate) async fn delete_market_price_snapshots_before( pool: &DbPool, cutoff: i64, ) -> Result { + sqlx::query("DELETE FROM market_books WHERE observed_at < ?") + .bind(cutoff) + .execute(pool) + .await?; sqlx::query("DELETE FROM market_price_snapshots WHERE observed_at < ?") .bind(cutoff) .execute(pool) @@ -407,4 +460,100 @@ mod tests { assert_eq!(previous[0].midpoint, "10"); assert_eq!(previous[1].midpoint, "20"); } + fn book(bid: Option<&str>, ask: Option<&str>, observed_at: i64) -> NewMarketBookSnapshot { + NewMarketBookSnapshot { + chain_id: 8453, + asset_token_address: "0xasset".into(), + quote_token_address: "0xquote".into(), + best_bid: bid.map(str::to_string), + best_ask: ask.map(str::to_string), + assets_per_share: "1".into(), + observed_at, + } + } + + #[tokio::test] + async fn executable_book_replacement_clears_missing_sides_and_rejects_older_samples() { + let pool = test_pool().await; + for sample in [ + book(Some("0.51"), Some("1.19"), 100), + book(Some("0.52"), None, 160), + book(None, None, 180), + ] { + replace_market_price_sample_with_books( + &pool, + sample.observed_at, + &[], + std::slice::from_ref(&sample), + ) + .await + .expect("replace book"); + let rows = list_market_books(&pool, 8453, "0xquote", 0) + .await + .expect("read book"); + assert_eq!(rows, vec![sample]); + } + replace_market_price_sample_with_books(&pool, 160, &[], &[book(Some("0.52"), None, 160)]) + .await + .expect("older sample"); + assert_eq!( + list_market_books(&pool, 8453, "0xquote", 0) + .await + .expect("read book"), + vec![book(None, None, 180)] + ); + replace_market_price_sample_with_books(&pool, 180, &[], &[book(None, Some("1.2"), 180)]) + .await + .expect("same bucket replacement"); + assert_eq!( + list_market_books(&pool, 8453, "0xquote", 0) + .await + .expect("read book"), + vec![book(None, Some("1.2"), 180)] + ); + delete_market_price_snapshots_before(&pool, 181) + .await + .expect("prune books"); + assert!(list_market_books(&pool, 8453, "0xquote", 0) + .await + .expect("read book") + .is_empty()); + } + + #[tokio::test] + async fn books_and_midpoints_commit_atomically() { + let pool = test_pool().await; + replace_market_price_sample_with_books( + &pool, + 100, + &[snapshot("0xasset", "10", 100)], + &[book(Some("9"), Some("11"), 100)], + ) + .await + .expect("initial sample"); + sqlx::query("CREATE TRIGGER reject_book BEFORE UPDATE ON market_books BEGIN SELECT RAISE(ABORT, 'test rollback'); END").execute(&pool).await.expect("trigger"); + assert!(replace_market_price_sample_with_books( + &pool, + 100, + &[snapshot("0xasset", "20", 100)], + &[book(Some("19"), Some("21"), 100)] + ) + .await + .is_err()); + assert_eq!( + list_market_prices_at_or_before(&pool, 8453, "0xquote", 0, 100) + .await + .expect("read midpoint")[0] + .midpoint, + "10" + ); + assert_eq!( + list_market_books(&pool, 8453, "0xquote", 0) + .await + .expect("read book")[0] + .best_bid + .as_deref(), + Some("9") + ); + } } diff --git a/src/market_price.rs b/src/market_price.rs index f15012f..cf21c08 100644 --- a/src/market_price.rs +++ b/src/market_price.rs @@ -1,7 +1,8 @@ use crate::atomic_liquidity::has_executable_atomic_amounts; use crate::cache::RouteResponseCaches; use crate::db::market_price_history::{ - delete_market_price_snapshots_before, replace_market_price_sample, NewMarketPriceSnapshot, + delete_market_price_snapshots_before, replace_market_price_sample_with_books, + NewMarketBookSnapshot, NewMarketPriceSnapshot, }; use crate::db::DbPool; use crate::error::ApiError; @@ -166,6 +167,8 @@ enum MarketSide { #[derive(Debug, Clone, Copy)] struct ObservedQuote { asset_address: Address, + // Normalization changes price units, not which token the order can execute. + source_asset_address: Address, side: MarketSide, price: Float, } @@ -182,6 +185,26 @@ struct Book { best_ask: Option, } +impl Book { + fn observe(&mut self, observation: &ObservedQuote) -> Result<(), ApiError> { + match observation.side { + MarketSide::Bid => { + self.best_bid = Some(match self.best_bid { + Some(current) => current.max(observation.price).map_err(float_error)?, + None => observation.price, + }); + } + MarketSide::Ask => { + self.best_ask = Some(match self.best_ask { + Some(current) => current.min(observation.price).map_err(float_error)?, + None => observation.price, + }); + } + } + Ok(()) + } +} + #[derive(Clone)] pub(crate) struct MarketPriceSampler { state: MarketPriceState, @@ -274,9 +297,13 @@ impl MarketPriceSampler { .await; let mut snapshots = Vec::new(); + let mut books = Vec::new(); for (market, result) in results { match result { - Ok(mut market_snapshots) => snapshots.append(&mut market_snapshots), + Ok(mut sample) => { + snapshots.append(&mut sample.snapshots); + books.append(&mut sample.books); + } Err(error) => { tracing::error!( chain_id = market.chain_id, @@ -305,17 +332,22 @@ impl MarketPriceSampler { tracing::error!(error = %error, "failed to prune market price snapshots"); ApiError::Internal("failed to prune market price snapshots".into()) })?; - let (replaced, inserted) = - replace_market_price_sample(&self.state.pool, observed_at, &snapshots) - .await - .map_err(|error| { - tracing::error!(error = %error, "failed to persist market price snapshots"); - ApiError::Internal("failed to persist market price snapshots".into()) - })?; + let (replaced, inserted) = replace_market_price_sample_with_books( + &self.state.pool, + observed_at, + &snapshots, + &books, + ) + .await + .map_err(|error| { + tracing::error!(error = %error, "failed to persist market price snapshots"); + ApiError::Internal("failed to persist market price snapshots".into()) + })?; tracing::info!( market_count, snapshot_count = snapshots.len(), + book_count = books.len(), inserted, replaced, deleted, @@ -329,7 +361,7 @@ impl MarketPriceSampler { client: &RaindexClient, market: &PriceMarket, observed_at: i64, - ) -> Result, ApiError> { + ) -> Result { tracing::info!( chain_id = market.chain_id, quote_token = %market.quote_token_address, @@ -379,7 +411,7 @@ impl MarketPriceSampler { &token_decimals, )?); } - let snapshots = aggregate_observations( + let sample = aggregate_market_prices( &market.tokens, &observations, market.chain_id, @@ -391,10 +423,11 @@ impl MarketPriceSampler { chain_id = market.chain_id, order_count = orders.len(), observation_count = observations.len(), - snapshot_count = snapshots.len(), + snapshot_count = sample.snapshots.len(), + book_count = sample.books.len(), "price market sampling complete" ); - Ok(snapshots) + Ok(sample) } } @@ -946,6 +979,7 @@ fn observed_quotes_from_decoded_order( match variant_map.get(&output_address) { Some(variant) => Some(ObservedQuote { asset_address: variant.canonical_address, + source_asset_address: output_address, side: MarketSide::Ask, price: normalize_variant_price(data.ratio, *variant)?, }), @@ -955,6 +989,7 @@ fn observed_quotes_from_decoded_order( match variant_map.get(&input_address) { Some(variant) => Some(ObservedQuote { asset_address: variant.canonical_address, + source_asset_address: input_address, side: MarketSide::Bid, price: normalize_variant_price(data.inverse_ratio, *variant)?, }), @@ -993,54 +1028,69 @@ fn normalize_variant_price(price: Float, variant: MarketVariant) -> Result, + books: Vec, +} + +fn aggregate_market_prices( tokens: &[MarketToken], observations: &[ObservedQuote], chain_id: u32, quote_token: Address, observed_at: i64, wrap_ratios: &HashMap, -) -> Result, ApiError> { +) -> Result { let mut books = BTreeMap::::new(); + let mut executable_books = BTreeMap::::new(); for observation in observations { - let book = books.entry(observation.asset_address).or_default(); - match observation.side { - MarketSide::Bid => { - book.best_bid = Some(match book.best_bid { - Some(current) => current.max(observation.price).map_err(float_error)?, - None => observation.price, - }); - } - MarketSide::Ask => { - book.best_ask = Some(match book.best_ask { - Some(current) => current.min(observation.price).map_err(float_error)?, - None => observation.price, - }); - } + books + .entry(observation.asset_address) + .or_default() + .observe(observation)?; + // The swap endpoint matches exact token addresses. Underlying/legacy + // orders contribute to display prices but cannot anchor a canonical swap. + if observation.source_asset_address == observation.asset_address { + executable_books + .entry(observation.asset_address) + .or_default() + .observe(observation)?; } } let two = Float::parse("2".to_string()).map_err(float_error)?; let quote_token_address = normalize_address(quote_token); - let mut snapshots = Vec::new(); + let mut sample = MarketPriceSample { + snapshots: Vec::new(), + books: Vec::with_capacity(tokens.len()), + }; for token in tokens { - let Some(book) = books.get(&token.canonical_address) else { - continue; - }; - let (Some(best_bid), Some(best_ask)) = (book.best_bid, book.best_ask) else { - continue; - }; - if best_bid.gt(best_ask).map_err(float_error)? { - tracing::warn!( - asset_token = %token.canonical_address, - "ignoring crossed market price book" - ); - continue; + let book = books.remove(&token.canonical_address).unwrap_or_default(); + let mut executable_book = executable_books + .remove(&token.canonical_address) + .unwrap_or_default(); + let (mut best_bid, mut best_ask) = (book.best_bid, book.best_ask); + if let (Some(bid), Some(ask)) = (best_bid, best_ask) { + if bid.gt(ask).map_err(float_error)? { + tracing::warn!( + asset_token = %token.canonical_address, + "ignoring crossed market price book" + ); + best_bid = None; + best_ask = None; + } + } + if let (Some(bid), Some(ask)) = (executable_book.best_bid, executable_book.best_ask) { + if bid.gt(ask).map_err(float_error)? { + tracing::warn!( + asset_token = %token.canonical_address, + "ignoring crossed canonical executable book" + ); + executable_book.best_bid = None; + executable_book.best_ask = None; + } } - let midpoint = best_bid - .add(best_ask) - .and_then(|sum| sum.div(two)) - .map_err(float_error)?; let assets_per_share = wrap_ratios .get(&token.canonical_address) .ok_or_else(|| { @@ -1052,18 +1102,65 @@ fn aggregate_observations( })? .assets_per_share .clone(); - snapshots.push(NewMarketPriceSnapshot { + let best_bid_string = executable_book + .best_bid + .map(Float::format) + .transpose() + .map_err(float_error)?; + let best_ask_string = executable_book + .best_ask + .map(Float::format) + .transpose() + .map_err(float_error)?; + if let (Some(bid), Some(ask)) = (best_bid, best_ask) { + let midpoint = bid + .add(ask) + .and_then(|sum| sum.div(two)) + .map_err(float_error)?; + sample.snapshots.push(NewMarketPriceSnapshot { + chain_id: i64::from(chain_id), + asset_token_address: normalize_address(token.canonical_address), + quote_token_address: quote_token_address.clone(), + best_bid: bid.format().map_err(float_error)?, + best_ask: ask.format().map_err(float_error)?, + midpoint: midpoint.format().map_err(float_error)?, + assets_per_share: assets_per_share.clone(), + observed_at, + }); + } + // Include empty books so a completed sample clears previously available + // sides rather than accidentally carrying them forward as current. + sample.books.push(NewMarketBookSnapshot { chain_id: i64::from(chain_id), asset_token_address: normalize_address(token.canonical_address), quote_token_address: quote_token_address.clone(), - best_bid: best_bid.format().map_err(float_error)?, - best_ask: best_ask.format().map_err(float_error)?, - midpoint: midpoint.format().map_err(float_error)?, + best_bid: best_bid_string, + best_ask: best_ask_string, assets_per_share, observed_at, }); } - Ok(snapshots) + Ok(sample) +} + +#[cfg(test)] +fn aggregate_observations( + tokens: &[MarketToken], + observations: &[ObservedQuote], + chain_id: u32, + quote_token: Address, + observed_at: i64, + wrap_ratios: &HashMap, +) -> Result, ApiError> { + aggregate_market_prices( + tokens, + observations, + chain_id, + quote_token, + observed_at, + wrap_ratios, + ) + .map(|sample| sample.snapshots) } fn float_error(error: rain_math_float::FloatError) -> ApiError { @@ -1160,6 +1257,7 @@ mod tests { fn observation(side: MarketSide, price: &str) -> ObservedQuote { ObservedQuote { asset_address: ASSET, + source_asset_address: ASSET, side, price: Float::parse(price.to_string()).expect("valid test price"), } @@ -1237,6 +1335,236 @@ mod tests { assert!(crossed.is_empty()); } + #[test] + fn keeps_each_executable_side_without_fabricating_a_midpoint() { + for (side, price, bid, ask) in [ + (MarketSide::Bid, "0.5109", Some("0.5109"), None), + (MarketSide::Ask, "1.1921", None, Some("1.1921")), + ] { + let sample = aggregate_market_prices( + &[token()], + &[observation(side, price)], + 4663, + QUOTE, + 120, + &wrap_ratios(), + ) + .expect("aggregate one-sided executable book"); + assert!(sample.snapshots.is_empty()); + assert_eq!(sample.books.len(), 1); + assert_eq!(sample.books[0].best_bid.as_deref(), bid); + assert_eq!(sample.books[0].best_ask.as_deref(), ask); + assert_eq!(sample.books[0].observed_at, 120); + } + } + + #[test] + fn empty_and_crossed_samples_explicitly_clear_executable_sides() { + for observations in [ + vec![], + vec![ + observation(MarketSide::Bid, "12"), + observation(MarketSide::Ask, "11"), + ], + ] { + let sample = aggregate_market_prices( + &[token()], + &observations, + 4663, + QUOTE, + 120, + &wrap_ratios(), + ) + .expect("aggregate unavailable book"); + assert!(sample.snapshots.is_empty()); + assert_eq!(sample.books.len(), 1); + assert!(sample.books[0].best_bid.is_none()); + assert!(sample.books[0].best_ask.is_none()); + } + } + + #[test] + fn unfunded_snes_ask_is_excluded_while_funded_bid_is_published() { + let ratio = "1.957330201605010765316108827559209238598551575650812292033666079467"; + let variant_map = HashMap::from([( + ASSET, + MarketVariant { + canonical_address: ASSET, + price_multiplier: Float::parse("1".into()).expect("identity"), + }, + )]); + let funded_bid = crate::test_helpers::wtmstr_quote("10560", "20669", ratio, "0.5109"); + let empty_ask = crate::test_helpers::wtmstr_quote("0", "0", "1.1921", "0.83885"); + let mut observations = observed_quotes_from_decoded_order( + &single_pair_order(ASSET, QUOTE), + &[funded_bid], + QUOTE, + &variant_map, + &token_decimals(), + alloy::primitives::B256::ZERO, + ) + .expect("funded sell quote"); + observations.extend( + observed_quotes_from_decoded_order( + &single_pair_order(QUOTE, ASSET), + &[empty_ask], + QUOTE, + &variant_map, + &token_decimals(), + alloy::primitives::B256::ZERO, + ) + .expect("unfunded ask excluded"), + ); + let sample = + aggregate_market_prices(&[token()], &observations, 4663, QUOTE, 120, &wrap_ratios()) + .expect("one-sided SNES sample"); + assert!(sample.snapshots.is_empty()); + assert_eq!(sample.books[0].best_bid.as_deref(), Some("0.5109")); + assert!(sample.books[0].best_ask.is_none()); + } + + #[test] + fn wide_spread_keeps_real_side_prices_and_exact_display_midpoint() { + let sample = aggregate_market_prices( + &[token()], + &[ + observation(MarketSide::Bid, "0.5109"), + observation(MarketSide::Ask, "1.1921"), + ], + 4663, + QUOTE, + 120, + &wrap_ratios(), + ) + .expect("wide-spread book"); + assert_eq!(sample.snapshots[0].midpoint, "0.8515"); + assert_eq!(sample.books[0].best_bid.as_deref(), Some("0.5109")); + assert_eq!(sample.books[0].best_ask.as_deref(), Some("1.1921")); + } + + #[test] + fn better_variant_prices_do_not_anchor_canonical_executable_sides() { + for variant in [ + address!("4444444444444444444444444444444444444444"), + ASSET_TWO, + ] { + let mut variant_bid = observation(MarketSide::Bid, "11"); + variant_bid.source_asset_address = variant; + let mut variant_ask = observation(MarketSide::Ask, "12"); + variant_ask.source_asset_address = variant; + let sample = aggregate_market_prices( + &[token()], + &[ + observation(MarketSide::Bid, "10"), + observation(MarketSide::Ask, "13"), + variant_bid, + variant_ask, + ], + 8453, + QUOTE, + 120, + &wrap_ratios(), + ) + .expect("aggregate canonical and variant prices"); + assert_eq!(sample.snapshots[0].best_bid, "11"); + assert_eq!(sample.snapshots[0].best_ask, "12"); + assert_eq!(sample.snapshots[0].midpoint, "11.5"); + assert_eq!(sample.books[0].best_bid.as_deref(), Some("10")); + assert_eq!(sample.books[0].best_ask.as_deref(), Some("13")); + } + } + + #[test] + fn variant_only_sample_preserves_display_midpoint_and_clears_canonical_book() { + let mut variant_bid = observation(MarketSide::Bid, "11"); + variant_bid.source_asset_address = ASSET_TWO; + let mut variant_ask = observation(MarketSide::Ask, "12"); + variant_ask.source_asset_address = ASSET_TWO; + let sample = aggregate_market_prices( + &[token()], + &[variant_bid, variant_ask], + 8453, + QUOTE, + 120, + &wrap_ratios(), + ) + .expect("aggregate variant-only display prices"); + assert_eq!(sample.snapshots[0].midpoint, "11.5"); + assert_eq!(sample.books.len(), 1); + assert!(sample.books[0].best_bid.is_none()); + assert!(sample.books[0].best_ask.is_none()); + } + + #[test] + fn crossed_variant_display_book_does_not_clear_valid_canonical_liquidity() { + let mut variant_bid = observation(MarketSide::Bid, "14"); + variant_bid.source_asset_address = ASSET_TWO; + let sample = aggregate_market_prices( + &[token()], + &[ + observation(MarketSide::Bid, "10"), + observation(MarketSide::Ask, "13"), + variant_bid, + ], + 8453, + QUOTE, + 120, + &wrap_ratios(), + ) + .expect("independent crossed-book handling"); + assert!(sample.snapshots.is_empty()); + assert_eq!(sample.books[0].best_bid.as_deref(), Some("10")); + assert_eq!(sample.books[0].best_ask.as_deref(), Some("13")); + } + + #[test] + fn decoded_observations_preserve_actual_asset_token_after_normalization() { + for source_asset in [ASSET, ASSET_TWO] { + let variant_map = HashMap::from([( + source_asset, + MarketVariant { + canonical_address: ASSET, + price_multiplier: Float::parse("2".into()).expect("test multiplier"), + }, + )]); + let decimals = HashMap::from([(source_asset, 18), (QUOTE, 6)]); + let quote = crate::test_helpers::wtmstr_quote("100", "1000", "2", "0.5"); + for (input, output, side, price) in [ + (QUOTE, source_asset, MarketSide::Ask, "4"), + (source_asset, QUOTE, MarketSide::Bid, "1"), + ] { + let observations = observed_quotes_from_decoded_order( + &single_pair_order(input, output), + std::slice::from_ref("e), + QUOTE, + &variant_map, + &decimals, + alloy::primitives::B256::ZERO, + ) + .expect("decode funded order observation"); + assert_eq!(observations.len(), 1); + assert_eq!(observations[0].asset_address, ASSET); + assert_eq!(observations[0].source_asset_address, source_asset); + assert_eq!(observations[0].side, side); + assert_eq!(observations[0].price.format().expect("format"), price); + let sample = aggregate_market_prices( + &[token()], + &observations, + 8453, + QUOTE, + 120, + &wrap_ratios(), + ) + .expect("aggregate decoded source token"); + let reference = match side { + MarketSide::Bid => sample.books[0].best_bid.as_deref(), + MarketSide::Ask => sample.books[0].best_ask.as_deref(), + }; + assert_eq!(reference, (source_asset == ASSET).then_some(price)); + } + } + } + #[test] fn normalizes_underlying_and_legacy_variants_to_canonical_wrapped_units() { let unwrapped = address!("4444444444444444444444444444444444444444"); diff --git a/src/routes/prices.rs b/src/routes/prices.rs index 5e87f63..021359a 100644 --- a/src/routes/prices.rs +++ b/src/routes/prices.rs @@ -1,6 +1,6 @@ use crate::auth::AuthenticatedKey; use crate::db::market_price_history::{ - list_current_and_previous_market_prices, list_market_price_history, + list_current_and_previous_market_prices, list_market_books, list_market_price_history, list_market_prices_at_or_before, MarketPriceSnapshot, }; use crate::error::{ApiError, ApiErrorResponse}; @@ -68,6 +68,24 @@ pub enum MarketPriceSource { Unavailable, } +#[derive(Debug, Clone, Serialize, ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum ExecutableBookSource { + Live, + Cached, +} + +#[derive(Debug, Clone, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ExecutableBookResponse { + #[schema(nullable = true, example = "0.5109")] + best_bid: Option, + #[schema(nullable = true, example = "1.1921")] + best_ask: Option, + observed_at: i64, + source: ExecutableBookSource, +} + #[derive(Debug, Clone, Serialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct MarketPriceResponse { @@ -90,6 +108,7 @@ pub struct MarketPriceResponse { observed_at: Option, #[schema(nullable = true, example = "1.42")] change_24h_percent: Option, + executable_book: Option, } #[derive(Debug, Clone, Serialize, ToSchema)] @@ -272,6 +291,22 @@ async fn price_responses_for_market( ) .await?; + let books_by_asset = if historical { + HashMap::new() + } else { + list_market_books( + &state.pool, + i64::from(market.chain_id), + &normalize_address(market.quote_token_address), + retained_start, + ) + .await + .map_err(database_error)? + .into_iter() + .map(|book| (book.asset_token_address.clone(), book)) + .collect::>() + }; + market .tokens .iter() @@ -303,7 +338,7 @@ async fn price_responses_for_market( _ => None, } }; - price_response( + let mut response = price_response( config, market, token, @@ -311,7 +346,25 @@ async fn price_responses_for_market( historical, now, change_24h_percent, - ) + )?; + if let Some(book) = books_by_asset.get(&normalize_address(token.canonical_address)) { + let live_window = duration_seconds_i64(config.sample_interval, "sample interval")? + .saturating_mul(2); + response.executable_book = Some(ExecutableBookResponse { + best_bid: book.best_bid.clone(), + best_ask: book.best_ask.clone(), + observed_at: book.observed_at, + source: if book.observed_at > 0 + && book.observed_at <= now + && now.saturating_sub(book.observed_at) <= live_window + { + ExecutableBookSource::Live + } else { + ExecutableBookSource::Cached + }, + }); + } + Ok(response) }) .collect() } @@ -494,6 +547,7 @@ fn price_response( source, observed_at: row.map(|row| row.observed_at), change_24h_percent, + executable_book: None, }) } @@ -931,6 +985,7 @@ using-tokens-from: assert!(body["data"][0].get("observedAt").is_some()); assert!(body["data"][0].get("change24hPercent").is_some()); assert!(body["data"][0].get("asset_address").is_none()); + assert!(body["data"][0]["executableBook"].is_null()); } #[rocket::async_test] @@ -1100,4 +1155,127 @@ using-tokens-from: let response = authorized_get(&client, &path).await; assert_eq!(response.status(), Status::BadRequest); } + async fn seed_book( + client: &rocket::local::asynchronous::Client, + bid: Option<&str>, + ask: Option<&str>, + observed_at: i64, + asset: Address, + ) { + let pool = client.rocket().state::().expect("pool"); + crate::db::market_price_history::replace_market_price_sample_with_books( + pool, + observed_at, + &[], + &[crate::db::market_price_history::NewMarketBookSnapshot { + chain_id: 8453, + asset_token_address: normalize_address(asset), + quote_token_address: normalize_address(QUOTE), + best_bid: bid.map(str::to_string), + best_ask: ask.map(str::to_string), + assets_per_share: "1".into(), + observed_at, + }], + ) + .await + .expect("seed book"); + } + + #[rocket::async_test] + async fn retained_midpoint_and_one_sided_book_survive_restart_independently() { + let directory = tempfile::tempdir().expect("database directory"); + let database_url = format!("sqlite://{}", directory.path().join("books.db").display()); + let client = price_client_with_database_url(Some(database_url.clone())).await; + let now = unix_now().expect("time"); + seed_price(&client, now - 3600, "0.8395").await; + seed_book(&client, Some("0.5109"), None, now, ASSET).await; + drop(client); + let client = price_client_with_database_url(Some(database_url)).await; + let body: serde_json::Value = authorized_get(&client, "/v1/prices?chainId=8453") + .await + .into_json() + .await + .expect("response"); + let price = &body["data"][0]; + assert_eq!(price["source"], "cached"); + assert_eq!(price["midpoint"], "0.8395"); + assert_eq!(price["executableBook"]["source"], "live"); + assert_eq!(price["executableBook"]["bestBid"], "0.5109"); + assert!(price["executableBook"]["bestAsk"].is_null()); + assert_eq!(price["executableBook"]["observedAt"], now); + let path = format!("/v1/prices?chainId=8453&at={now}"); + let historical: serde_json::Value = authorized_get(&client, &path) + .await + .into_json() + .await + .expect("historical"); + assert!(historical["data"][0]["executableBook"].is_null()); + } + + #[rocket::async_test] + async fn executable_book_available_without_midpoint_and_empty_sample_clears_liquidity() { + let client = price_client().await; + let now = unix_now().expect("time"); + seed_book(&client, None, Some("1.1921"), now - 60, ASSET).await; + let body: serde_json::Value = authorized_get(&client, "/v1/prices?chainId=8453") + .await + .into_json() + .await + .expect("response"); + assert_eq!(body["data"][0]["source"], "unavailable"); + assert!(body["data"][0]["midpoint"].is_null()); + assert_eq!(body["data"][0]["executableBook"]["bestAsk"], "1.1921"); + seed_book(&client, None, None, now, ASSET).await; + let body: serde_json::Value = authorized_get(&client, "/v1/prices?chainId=8453") + .await + .into_json() + .await + .expect("response"); + let book = &body["data"][0]["executableBook"]; + assert_eq!(book["source"], "live"); + assert!(book["bestBid"].is_null()); + assert!(book["bestAsk"].is_null()); + } + + #[rocket::async_test] + async fn books_fail_closed_for_expired_future_and_rotated_canonical_tokens() { + let client = price_client().await; + let now = unix_now().expect("time"); + // Legacy token observations are never reused as canonical executable liquidity. + seed_book(&client, Some("9"), Some("11"), now, LEGACY).await; + let body: serde_json::Value = authorized_get(&client, "/v1/prices?chainId=8453") + .await + .into_json() + .await + .expect("response"); + assert!(body["data"][0]["executableBook"].is_null()); + seed_book(&client, Some("9"), None, now - 121, ASSET).await; + let body: serde_json::Value = authorized_get(&client, "/v1/prices?chainId=8453") + .await + .into_json() + .await + .expect("response"); + assert_eq!(body["data"][0]["executableBook"]["source"], "cached"); + seed_book(&client, Some("9"), None, now + 60, ASSET).await; + let body: serde_json::Value = authorized_get(&client, "/v1/prices?chainId=8453") + .await + .into_json() + .await + .expect("response"); + assert_eq!(body["data"][0]["executableBook"]["source"], "cached"); + } + #[rocket::async_test] + async fn executable_book_outside_retention_is_unavailable() { + let client = price_client().await; + let now = unix_now().expect("time"); + let state = client.rocket().state::().expect("state"); + let retention = i64::try_from(state.config.retention.as_secs()).expect("retention"); + seed_book(&client, Some("9"), None, now - retention - 1, ASSET).await; + let body: serde_json::Value = authorized_get(&client, "/v1/prices?chainId=8453") + .await + .into_json() + .await + .expect("response"); + assert!(body["data"][0]["executableBook"].is_null()); + } } diff --git a/src/routes/swap/slippage.rs b/src/routes/swap/slippage.rs index db3bff5..fc84491 100644 --- a/src/routes/swap/slippage.rs +++ b/src/routes/swap/slippage.rs @@ -322,6 +322,78 @@ mod tests { assert_eq!(cap.format().unwrap(), "1.005"); } + #[test] + fn directional_references_handle_different_spreads_without_widening_guards() { + let one = Float::parse("1".into()).unwrap(); + let multiplier = Float::parse("1.01".into()).unwrap(); + for (bid, ask) in [ + ("0.99", "1.01"), + ("0.96", "1.04"), + ("0.8", "1.2"), + ("0.6", "1.4"), + ] { + for (mode, reference) in [ + (TakeOrdersMode::BuyUpTo, Float::parse(ask.into()).unwrap()), + ( + TakeOrdersMode::SpendUpTo, + one.div(Float::parse(bid.into()).unwrap()).unwrap(), + ), + ] { + let ratio = reference.format().unwrap(); + let cap = resolve_slippage_price_cap( + vec![candidate(1, "10", &ratio)], + mode, + "1", + 100, + Some(reference), + ) + .unwrap(); + assert!(cap.eq(reference.mul(multiplier).unwrap()).unwrap()); + let worse = reference.mul(Float::parse("1.06".into()).unwrap()).unwrap(); + assert!( + resolve_slippage_price_cap( + vec![candidate(1, "10", &worse.format().unwrap())], + mode, + "1", + 100, + Some(reference), + ) + .is_err(), + "the five-percent guard must still reject worse prices" + ); + } + } + } + + #[test] + fn fresh_snes_midpoint_still_rejects_sell_but_current_bid_succeeds() { + let ratio = "1.957330201605010765316108827559209238598551575650812292033666079467"; + let one = Float::parse("1".into()).unwrap(); + let midpoint_reference = one.div(Float::parse("0.8515".into()).unwrap()).unwrap(); + assert!(resolve_slippage_price_cap( + vec![candidate(1, "10560", ratio)], + TakeOrdersMode::SpendUpTo, + "3.401414549816231653", + 100, + Some(midpoint_reference), + ) + .is_err()); + let bid_reference = Float::parse(ratio.into()).unwrap(); + let cap = resolve_slippage_price_cap( + vec![candidate(1, "10560", ratio)], + TakeOrdersMode::SpendUpTo, + "3.401414549816231653", + 100, + Some(bid_reference), + ) + .unwrap(); + assert!(cap + .eq(bid_reference + .mul(Float::parse("1.01".into()).unwrap()) + .unwrap()) + .unwrap()); + } + #[test] fn rejects_non_positive_reference_price() { let result = resolve_slippage_price_cap(