Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 47 additions & 10 deletions docs/src/prices.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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"
}
}
]
}
Expand All @@ -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
Expand All @@ -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

Expand Down
10 changes: 10 additions & 0 deletions migrations/20261006000000_create_market_books.sql
Original file line number Diff line number Diff line change
@@ -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,
Comment thread
findolor marked this conversation as resolved.
observed_at INTEGER NOT NULL,
PRIMARY KEY (chain_id, asset_token_address, quote_token_address)
);
149 changes: 149 additions & 0 deletions src/db/market_price_history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
pub best_ask: Option<String>,
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<Vec<NewMarketBookSnapshot>, 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,
Expand Down Expand Up @@ -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 = ?")
Expand All @@ -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))
}

Expand Down Expand Up @@ -178,6 +227,10 @@ pub(crate) async fn delete_market_price_snapshots_before(
pool: &DbPool,
cutoff: i64,
) -> Result<u64, sqlx::Error> {
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)
Expand Down Expand Up @@ -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")
);
}
}
Loading
Loading