Skip to content
Open
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
4 changes: 2 additions & 2 deletions docs/ops.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/cache_warmer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
81 changes: 65 additions & 16 deletions src/routes/orders/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand All @@ -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<SignedContextV1> {
// 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<SignedContextV1> {
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![];
Expand All @@ -63,22 +105,29 @@ async fn fetch_oracle_context(oracle_url: &str) -> Vec<SignedContextV1> {
}
};

// 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::<Vec<SignedContextV1>>(&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::<SignedContextV1>(&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![]
Expand All @@ -91,7 +140,7 @@ async fn fetch_oracle_contexts_for_orders(orders: &[RaindexOrder]) -> Vec<Vec<Si
.iter()
.map(|order| async move {
match order.oracle_url() {
Some(url) => fetch_oracle_context(&url).await,
Some(url) => fetch_oracle_context(order, &url).await,
None => vec![],
}
})
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(),
})
}

Expand Down
14 changes: 14 additions & 0 deletions src/types/orders.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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": "<url>"}` etc.).
#[schema(value_type = Vec<serde_json::Value>)]
pub parsed_meta: Vec<ParsedMeta>,
}

#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
Expand Down
Loading