From 45befee4a2b08281998f22809151fc21cb03679d Mon Sep 17 00:00:00 2001 From: Alastair Ong Date: Wed, 29 Apr 2026 09:49:32 +0100 Subject: [PATCH 1/2] orders: quote signed-context oracles server-side, expose parsedMeta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes that together let signed-context oracle orders (e.g. SPYM) return real `ioRatio` values from `/v1/orders/token/*` and `/v1/orders/owner/*` instead of the `"-"` sentinel: 1. **`fetch_oracle_context` now sends the right body.** The previous implementation POSTed `abi.encode(empty bytes)` (just `0x20` || `0x00`) to the maker's oracle URL, which the server (st0x-oracle-server) 400'd with `Invalid ABI-encoded body`. As a result every signed-context `quote()` call fell through with empty `signedContext` and the order's Rainlang reverted with `signed-context out of bounds` — the API then reported `ioRatio: "-"`, which the frontend would helpfully synthesise from oracle price plus phantom liquidity. The fix matches what the oracle server's own smoke test does: it ABI-encodes the tuple `(OrderV4, U256 inputIOIndex, U256 outputIOIndex, address counterparty)` via `SolValue::abi_encode`, with `(0, 0, address(0))` for the IO indices and counterparty. The orderbook contract then runs the order's full Rainlang including `× baseline- multiplier`, so the returned `io_ratio` is the exact ratio the order would fill at right now. Stale-oracle reverts (`Oracle data stale`) still surface as `"-"` — that's correct, the order legitimately can't fill outside market hours. Old orders with deprecated URLs (`/context` instead of `/context/v1`) 404 and also surface as `"-"` — also correct, they can't fill. Function signature changed from `(oracle_url: &str)` to `(order: &RaindexOrder, oracle_url: &str)` so we can build the request body. Success path now logs at INFO so deploys can verify the wiring without bumping `RUST_LOG`. 2. **`OrderSummary` exposes `parsed_meta: Vec`.** Since the server now does the oracle plumbing internally, frontends don't need this field to *quote* — but exposing it lets clients tag oracle-driven orders in the UI, read `DotrainSourceV1` for strategy configuration, etc. Mirrors the Rain SDK's `ParsedMeta` enum shape; schema declared as `Vec` because `ParsedMeta` lives outside this crate. Verified on api.preview.st0x.io — INFO logs show `fetched oracle signed context oracle_url=...context/v1 count=1` for SPYM orders. `ioRatio` populates when markets are open / oracle data is fresh; `"-"` only appears for orders that genuinely can't fill. --- src/routes/orders/mod.rs | 81 ++++++++++++++++++++++++++++++++-------- src/types/orders.rs | 14 +++++++ 2 files changed, 79 insertions(+), 16 deletions(-) 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)] From b3458360a96301dbfeefdfdf1f6f3cce5c1a381b Mon Sep 17 00:00:00 2001 From: Alastair Ong Date: Wed, 29 Apr 2026 15:04:09 +0100 Subject: [PATCH 2/2] Reduce order quote refresh cadence --- docs/ops.md | 4 ++-- src/cache_warmer.rs | 2 +- src/main.rs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) 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);