diff --git a/docs/ops.md b/docs/ops.md index ee817eb..2156de4 100644 --- a/docs/ops.md +++ b/docs/ops.md @@ -14,8 +14,8 @@ curl -sS https://api.preview.st0x.io/health/detailed | jq Key fields in `/health/detailed.cache_warmer`: - `running` — `false` until the warmer completes its first cycle (~15-30s after restart while caches are cold) -- `last_cycle_ms` — should track the steady-state cycle duration; sustained > 10s suggests upstream RPC slowness -- `seconds_since_last_complete` — should bounce between `0` and `~20` (cycle duration + REFRESH_INTERVAL); much higher means the warmer has frozen +- `last_cycle_ms` — should track the steady-state cycle duration; sustained high values suggest upstream RPC slowness +- `seconds_since_last_complete` — should bounce between `0` and roughly the 5 minute refresh interval plus cycle duration; much higher means the warmer has frozen - `last_errors` — per-token failures during the last cycle; non-zero is worth investigating ## Common journalctl queries diff --git a/src/cache_warmer.rs b/src/cache_warmer.rs index 7196675..af1c93b 100644 --- a/src/cache_warmer.rs +++ b/src/cache_warmer.rs @@ -8,7 +8,7 @@ use std::time::{Instant, SystemTime, UNIX_EPOCH}; use tokio::sync::RwLock; use tokio::time::{sleep, Duration}; -const REFRESH_INTERVAL: Duration = Duration::from_secs(10); +const REFRESH_INTERVAL: Duration = Duration::from_secs(5 * 60); /// Snapshot of the cache warmer's most recent activity, exposed via /// `/v1/health/detailed`. Mutated under a short-lived write lock at the end diff --git a/src/main.rs b/src/main.rs index a43df61..ef34762 100644 --- a/src/main.rs +++ b/src/main.rs @@ -359,7 +359,7 @@ async fn main() { let cache_warmer_stats = cache_warmer::shared_cache_warmer_stats(); // Spawn background task to keep the orders-by-token cache warm. - // Refreshes every 10s so real requests almost always hit the cache. + // Refreshes every 5 minutes to limit quote RPC volume. { let cache = orders_by_token_cache.clone(); let raindex = std::sync::Arc::clone(&shared_raindex); diff --git a/src/routes/orders/mod.rs b/src/routes/orders/mod.rs index dc00c3c..1d59224 100644 --- a/src/routes/orders/mod.rs +++ b/src/routes/orders/mod.rs @@ -7,9 +7,11 @@ mod stale_price_skip; use crate::error::ApiError; use crate::types::common::TokenRef; use crate::types::orders::{OrderSummary, OrdersListResponse, OrdersPagination}; +use alloy::primitives::{Address, U256}; +use alloy::sol_types::SolValue; use async_trait::async_trait; use futures::{future::join_all, stream, StreamExt}; -use rain_orderbook_bindings::IOrderBookV6::SignedContextV1; +use rain_orderbook_bindings::IOrderBookV6::{OrderV4, SignedContextV1}; use rain_orderbook_common::raindex_client::order_quotes::{ get_order_quotes_batch as fetch_order_quotes_batch, RaindexOrderQuote, }; @@ -22,29 +24,69 @@ pub(crate) const DEFAULT_PAGE_SIZE: u32 = 20; pub(crate) const MAX_PAGE_SIZE: u16 = 50; const MAX_CHAIN_BATCH_CONCURRENCY: usize = 4; -/// Fetch signed oracle context from an order's oracle URL. -/// Returns an empty vec if the order has no oracle URL or the fetch fails. +/// Fetch signed oracle context for a specific order from its oracle URL. /// -/// The oracle server expects a POST with an ABI-encoded `bytes` body. -/// An empty bytes value is: offset (0x20) + length (0x00), each as a 32-byte word. -async fn fetch_oracle_context(oracle_url: &str) -> Vec { - // ABI-encode an empty `bytes` value: offset=0x20, length=0 - let mut abi_body = vec![0u8; 64]; - abi_body[31] = 0x20; // offset = 32 +/// The oracle server (e.g. `st0x-oracle-server`) expects a POST whose body is +/// the ABI-encoding of `(OrderV4, uint256 inputIOIndex, uint256 outputIOIndex, +/// address counterparty)` — either as a single tuple or as a `Vec` of tuples +/// for batched requests. The server uses the IO indices to look up which +/// underlying symbol pair to sign a price for, then returns a JSON array of +/// `SignedContextV1`-shaped objects. +/// +/// We send a single tuple with `(input_io_index, output_io_index) = (0, 0)` +/// and `counterparty = Address::ZERO` (the server destructures counterparty as +/// `_counterparty`, so any value works). For st0x orders today (single input +/// × single output) this is the only valid pair; multi-IO orders would need +/// per-pair contexts which we don't support here yet. +/// +/// Returns an empty vec on any failure (network, ABI, JSON, etc.) so that +/// downstream quoting falls back gracefully — quote() will revert with no +/// signed context, the order will quote `"-"`, and the frontend will drop it. +async fn fetch_oracle_context( + order: &RaindexOrder, + oracle_url: &str, +) -> Vec { + let sg_order = match order.clone().into_sg_order() { + Ok(sg) => sg, + Err(e) => { + tracing::warn!( + oracle_url, + error = %e, + "failed to convert RaindexOrder to SgOrder for oracle request" + ); + return vec![]; + } + }; + let order_v4: OrderV4 = match sg_order.try_into() { + Ok(o) => o, + Err(e) => { + tracing::warn!( + oracle_url, + error = %e, + "failed to convert SgOrder to OrderV4 for oracle request" + ); + return vec![]; + } + }; + + let request_body = (order_v4, U256::ZERO, U256::ZERO, Address::ZERO).abi_encode(); let client = reqwest::Client::new(); let resp = match client .post(oracle_url) .header("Content-Type", "application/octet-stream") - .body(abi_body) + .body(request_body) .send() .await { Ok(resp) if resp.status().is_success() => resp, Ok(resp) => { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); tracing::warn!( oracle_url, - status = %resp.status(), + %status, + body = %body, "oracle endpoint returned non-success status" ); return vec![]; @@ -63,22 +105,29 @@ async fn fetch_oracle_context(oracle_url: &str) -> Vec { } }; - // Try parsing as array first, then as single object + // Server always returns a JSON array — even for single-tuple requests. + // Fall back to single-object parsing only as a defensive measure. if let Ok(contexts) = serde_json::from_str::>(&body) { - tracing::debug!( + tracing::info!( oracle_url, count = contexts.len(), + order_hash = ?order.order_hash(), "fetched oracle signed context" ); return contexts; } if let Ok(context) = serde_json::from_str::(&body) { - tracing::debug!(oracle_url, "fetched single oracle signed context"); + tracing::info!( + oracle_url, + order_hash = ?order.order_hash(), + "fetched single oracle signed context" + ); return vec![context]; } tracing::warn!( oracle_url, + body = %body, "failed to parse oracle response as SignedContextV1" ); vec![] @@ -91,7 +140,7 @@ async fn fetch_oracle_contexts_for_orders(orders: &[RaindexOrder]) -> Vec fetch_oracle_context(&url).await, + Some(url) => fetch_oracle_context(order, &url).await, None => vec![], } }) @@ -305,7 +354,6 @@ impl<'a> OrdersListDataSource for RaindexOrdersListDataSource<'a> { .first() .map(RaindexOrder::chain_id) .unwrap_or_default(); - // Fetch oracle signed context for orders that have an oracle URL. // This enables accurate quoting for oracle-dependent orders (e.g. SPYM). let signed_contexts = fetch_oracle_contexts_for_orders(orders).await; @@ -498,6 +546,7 @@ pub(crate) fn build_order_summary( io_ratio: quote.io_ratio.clone(), created_at, orderbook_id: order.orderbook(), + parsed_meta: order.parsed_meta(), }) } diff --git a/src/types/orders.rs b/src/types/orders.rs index c27ff94..4fe575a 100644 --- a/src/types/orders.rs +++ b/src/types/orders.rs @@ -1,5 +1,6 @@ use crate::types::common::TokenRef; use alloy::primitives::{Address, Bytes, FixedBytes}; +use rain_orderbook_common::parsed_meta::ParsedMeta; use rocket::form::{FromForm, FromFormField}; use serde::{Deserialize, Serialize}; use utoipa::{IntoParams, ToSchema}; @@ -62,6 +63,19 @@ pub struct OrderSummary { pub created_at: u64, #[schema(value_type = String, example = "0x1234567890abcdef1234567890abcdef12345678")] pub orderbook_id: Address, + /// Parsed Rain metadata items attached to the order. Orders that quote off + /// a signed-context oracle (`RaindexSignedContextOracleV1`) are quoted + /// server-side — the API fetches the maker's signed payload before calling + /// `quote()` — so consumers can rely on `io_ratio` directly without needing + /// any oracle plumbing of their own. This field is exposed so clients that + /// want extra detail (tagging oracle-driven orders, inspecting strategy + /// configuration via `DotrainSourceV1`, etc.) can read it. + /// + /// Schema is opaque JSON because `ParsedMeta` lives outside this crate; + /// the serialized shape matches the Rain SDK's `ParsedMeta` enum + /// (`{"RaindexSignedContextOracleV1": ""}` etc.). + #[schema(value_type = Vec)] + pub parsed_meta: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]