use std::{sync::Arc, time::Duration};
use async_trait::async_trait;
use ethrex_common::{Address, U256};
use serde_json::Value;
use crate::common::helpers::parse_address;
use crate::error::{Error, Result};
use crate::ws::{WsConnection, WsHandler};
pub const DEFAULT_PRICE_LEVELS_RPC_URL: &str = "https://rpc.titanbuilder.xyz";
pub const DEFAULT_PRICE_LEVELS_WS_URL: &str = "wss://eu.rpc.titanbuilder.xyz/ws/pamm_price_levels";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PriceVariant {
Simulated,
Interpolated,
}
impl PriceVariant {
fn parse(s: &str) -> Option<Self> {
match s {
"Simulated" => Some(Self::Simulated),
"Interpolated" => Some(Self::Interpolated),
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub struct PriceLevel {
pub amount_in: U256,
pub amount_out: U256,
pub variant: PriceVariant,
}
#[derive(Debug, Clone)]
pub struct PairPriceLevels {
pub token_in: Address,
pub token_out: Address,
pub order_book: Vec<PriceLevel>,
}
#[derive(Debug, Clone)]
pub struct PammPriceLevels {
pub pamm: Address,
pub pairs: Vec<PairPriceLevels>,
}
#[derive(Debug, Clone, Default)]
pub struct PriceLevelsSnapshot {
pub block_number: Option<u64>,
pub slot: Option<u64>,
pub timestamp_ns: Option<u64>,
pub pamms: Vec<PammPriceLevels>,
}
#[derive(Debug, Clone)]
pub struct TitanQuote {
pub token_in: Address,
pub token_out: Address,
pub amount_in: U256,
pub amount_out: U256,
pub pamm: Address,
pub router: Address,
pub block_number: Option<u64>,
pub slot: Option<u64>,
pub timestamp_ns: Option<u64>,
}
#[async_trait]
pub trait PriceLevelsSource: Send + Sync {
async fn get_price_levels(&self) -> Result<Arc<PriceLevelsSnapshot>>;
fn close(&self) {}
fn as_rpc_source(&self) -> Option<PriceLevelsRpcSource> {
None
}
}
pub fn parse_price_levels_message(raw: &Value) -> Result<PriceLevelsSnapshot> {
let object = raw
.as_object()
.ok_or_else(|| Error::Prices("price-levels message is not a JSON object".into()))?;
let pamms = object
.get("pamms")
.and_then(Value::as_array)
.map(|entries| entries.iter().filter_map(parse_pamm).collect())
.unwrap_or_default();
Ok(PriceLevelsSnapshot {
block_number: object
.get("blockNumber")
.or_else(|| object.get("block_number"))
.and_then(parse_u64_field),
slot: object.get("slot").and_then(parse_u64_field),
timestamp_ns: object.get("timestamp").and_then(Value::as_u64),
pamms,
})
}
fn parse_pamm(entry: &Value) -> Option<PammPriceLevels> {
let object = entry.as_object()?;
let pamm = object
.get("pamm")
.and_then(Value::as_str)
.and_then(|s| parse_address(s).ok())?;
let pairs = object
.get("pairs")
.and_then(Value::as_array)
.map(|entries| entries.iter().filter_map(parse_pair).collect())
.unwrap_or_default();
Some(PammPriceLevels { pamm, pairs })
}
fn parse_pair(entry: &Value) -> Option<PairPriceLevels> {
let object = entry.as_object()?;
let token_in = object
.get("tokenIn")
.and_then(Value::as_str)
.and_then(|s| parse_address(s).ok())?;
let token_out = object
.get("tokenOut")
.and_then(Value::as_str)
.and_then(|s| parse_address(s).ok())?;
let order_book = object
.get("orderBook")
.and_then(Value::as_array)
.map(|entries| entries.iter().filter_map(parse_level).collect())
.unwrap_or_default();
Some(PairPriceLevels {
token_in,
token_out,
order_book,
})
}
fn parse_level(entry: &Value) -> Option<PriceLevel> {
let object = entry.as_object()?;
let amount_in = object
.get("amountIn")
.and_then(Value::as_str)
.and_then(parse_u256_hex)?;
let amount_out = object
.get("amountOut")
.and_then(Value::as_str)
.and_then(parse_u256_hex)?;
let variant = object
.get("variant")
.and_then(Value::as_str)
.and_then(PriceVariant::parse)?;
Some(PriceLevel {
amount_in,
amount_out,
variant,
})
}
fn parse_titan_quote(raw: &Value) -> Result<TitanQuote> {
let object = raw
.as_object()
.ok_or_else(|| Error::Prices("price-levels quote result is not a JSON object".into()))?;
let address = |key: &str| {
object
.get(key)
.and_then(Value::as_str)
.and_then(|s| parse_address(s).ok())
};
let amount = |key: &str| {
object
.get(key)
.and_then(Value::as_str)
.and_then(parse_u256_hex)
};
let (
Some(token_in),
Some(token_out),
Some(pamm),
Some(router),
Some(amount_in),
Some(amount_out),
) = (
address("tokenIn"),
address("tokenOut"),
address("pamm"),
address("router"),
amount("amountIn"),
amount("amountOut"),
)
else {
return Err(Error::Prices(
"price-levels quote result is missing required fields".into(),
));
};
Ok(TitanQuote {
token_in,
token_out,
amount_in,
amount_out,
pamm,
router,
block_number: object
.get("blockNumber")
.or_else(|| object.get("block_number"))
.and_then(parse_u64_field),
slot: object.get("slot").and_then(parse_u64_field),
timestamp_ns: object.get("timestamp").and_then(Value::as_u64),
})
}
fn parse_u256_hex(s: &str) -> Option<U256> {
s.strip_prefix("0x")
.and_then(|h| U256::from_str_radix(h, 16).ok())
}
fn parse_u64_field(value: &Value) -> Option<u64> {
if let Some(number) = value.as_u64() {
return Some(number);
}
let text = value.as_str()?;
if let Some(hex) = text.strip_prefix("0x") {
u64::from_str_radix(hex, 16).ok()
} else {
text.parse().ok()
}
}
fn extract_rpc_result(body: &Value) -> Result<Value> {
if let Some(error) = body.get("error").filter(|error| !error.is_null()) {
return Err(Error::Prices(format!("price-levels RPC error: {error}")));
}
match body.get("result") {
Some(result) if !result.is_null() => Ok(result.clone()),
_ => Err(Error::Prices(
"price-levels RPC response had neither a result nor an error".into(),
)),
}
}
#[derive(Debug, Clone)]
pub struct PriceLevelsRpcSource {
url: String,
client: reqwest::Client,
}
impl Default for PriceLevelsRpcSource {
fn default() -> Self {
Self::new(DEFAULT_PRICE_LEVELS_RPC_URL)
}
}
impl PriceLevelsRpcSource {
pub fn new(url: impl Into<String>) -> Self {
Self {
url: url.into(),
client: reqwest::Client::new(),
}
}
pub async fn get_quote(
&self,
token_in: Address,
token_out: Address,
amount_in: U256,
) -> Result<TitanQuote> {
let params = serde_json::json!([
format!("{token_in:#x}"),
format!("{token_out:#x}"),
format!("{amount_in:#x}"),
]);
parse_titan_quote(&self.rpc("titan_getPammQuote", params).await?)
}
pub async fn get_quote_venue(
&self,
venue: Address,
token_in: Address,
token_out: Address,
amount_in: U256,
) -> Result<TitanQuote> {
let params = serde_json::json!([
format!("{venue:#x}"),
format!("{token_in:#x}"),
format!("{token_out:#x}"),
format!("{amount_in:#x}"),
]);
parse_titan_quote(&self.rpc("titan_getPammQuoteVenue", params).await?)
}
async fn rpc(&self, method: &str, params: Value) -> Result<Value> {
let response = self
.client
.post(&self.url)
.json(&serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": method,
"params": params,
}))
.send()
.await
.map_err(|e| Error::Prices(format!("price-levels RPC request failed: {e}")))?;
if !response.status().is_success() {
return Err(Error::Prices(format!(
"price-levels RPC request failed with status {}",
response.status()
)));
}
let body: Value = response
.json()
.await
.map_err(|e| Error::Prices(format!("price-levels RPC response is not JSON: {e}")))?;
extract_rpc_result(&body)
}
}
#[async_trait]
impl PriceLevelsSource for PriceLevelsRpcSource {
async fn get_price_levels(&self) -> Result<Arc<PriceLevelsSnapshot>> {
let result = self
.rpc("titan_getPammPriceLevels", serde_json::json!([]))
.await?;
parse_price_levels_message(&result).map(Arc::new)
}
fn as_rpc_source(&self) -> Option<PriceLevelsRpcSource> {
Some(self.clone())
}
}
#[derive(Debug, Clone)]
pub struct PriceLevelsWsSourceConfig {
pub url: String,
pub first_frame_timeout: Duration,
pub idle_timeout: Duration,
}
impl Default for PriceLevelsWsSourceConfig {
fn default() -> Self {
Self {
url: DEFAULT_PRICE_LEVELS_WS_URL.into(),
first_frame_timeout: Duration::from_secs(5),
idle_timeout: Duration::from_secs(30),
}
}
}
struct PriceLevelsWsHandler;
impl WsHandler for PriceLevelsWsHandler {
type Snapshot = PriceLevelsSnapshot;
fn apply_frame(snapshot: &mut Arc<Self::Snapshot>, text: &str) -> bool {
let Ok(value) = serde_json::from_str::<Value>(text) else {
return false;
};
let Ok(frame) = parse_price_levels_message(&value) else {
return false;
};
*snapshot = Arc::new(frame);
true
}
fn closed_error() -> Error {
Error::Prices("price-levels source is closed".into())
}
fn timeout_error(timeout: Duration) -> Error {
Error::Timeout(format!("no price-levels frame received within {timeout:?}"))
}
}
pub struct PriceLevelsWsSource {
conn: WsConnection<PriceLevelsWsHandler>,
}
impl Default for PriceLevelsWsSource {
fn default() -> Self {
Self::new(PriceLevelsWsSourceConfig::default())
}
}
impl PriceLevelsWsSource {
pub fn new(config: PriceLevelsWsSourceConfig) -> Self {
Self {
conn: WsConnection::new(config.url, config.idle_timeout, config.first_frame_timeout),
}
}
}
#[async_trait]
impl PriceLevelsSource for PriceLevelsWsSource {
async fn get_price_levels(&self) -> Result<Arc<PriceLevelsSnapshot>> {
self.conn.get_snapshot().await
}
fn close(&self) {
self.conn.close();
}
}
pub struct PriceLevels {
source: Arc<dyn PriceLevelsSource>,
rpc: PriceLevelsRpcSource,
}
impl Default for PriceLevels {
fn default() -> Self {
Self::new()
}
}
impl PriceLevels {
pub fn new() -> Self {
Self::with_source(Arc::new(PriceLevelsRpcSource::default()))
}
pub fn with_source(source: Arc<dyn PriceLevelsSource>) -> Self {
let rpc = source.as_rpc_source().unwrap_or_default();
Self { source, rpc }
}
pub fn with_source_and_rpc_url(
source: Arc<dyn PriceLevelsSource>,
rpc_url: impl Into<String>,
) -> Self {
let rpc = source
.as_rpc_source()
.unwrap_or_else(|| PriceLevelsRpcSource::new(rpc_url));
Self { source, rpc }
}
pub fn source(&self) -> &Arc<dyn PriceLevelsSource> {
&self.source
}
pub async fn get_price_levels(&self) -> Result<Arc<PriceLevelsSnapshot>> {
self.source.get_price_levels().await
}
pub async fn get_quote(
&self,
token_in: Address,
token_out: Address,
amount_in: U256,
) -> Result<TitanQuote> {
self.rpc.get_quote(token_in, token_out, amount_in).await
}
pub async fn get_quote_venue(
&self,
venue: Address,
token_in: Address,
token_out: Address,
amount_in: U256,
) -> Result<TitanQuote> {
self.rpc
.get_quote_venue(venue, token_in, token_out, amount_in)
.await
}
pub fn close(&self) {
self.source.close();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_price_levels_message_extracts_pamms_pairs_and_rungs() {
let pamm = "0x5979458912f80b96d30d4220af8e2e4925a33320";
let token_in = "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599";
let token_out = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
let raw = serde_json::json!({
"slot": 14581462,
"blockNumber": 25345763,
"timestamp": 1781801564588230787u64,
"pamms": [{
"pamm": pamm,
"pairs": [{
"tokenIn": token_in,
"tokenOut": token_out,
"orderBook": [
{ "amountIn": "0x989680", "amountOut": "0x174b67393", "variant": "Simulated" },
{ "amountIn": "0xaa810a", "amountOut": "0x1a0781260", "variant": "Interpolated" },
],
}],
}],
});
let snapshot = parse_price_levels_message(&raw).unwrap();
assert_eq!(snapshot.block_number, Some(25_345_763));
assert_eq!(snapshot.slot, Some(14_581_462));
assert_eq!(snapshot.timestamp_ns, Some(1_781_801_564_588_230_787));
assert_eq!(snapshot.pamms.len(), 1);
let entry = &snapshot.pamms[0];
assert_eq!(entry.pamm, parse_address(pamm).unwrap());
assert_eq!(entry.pairs.len(), 1);
let pair = &entry.pairs[0];
assert_eq!(pair.token_in, parse_address(token_in).unwrap());
assert_eq!(pair.token_out, parse_address(token_out).unwrap());
assert_eq!(pair.order_book.len(), 2);
assert_eq!(pair.order_book[0].amount_in, U256::from(0x989680u64));
assert_eq!(pair.order_book[0].amount_out, U256::from(0x1_74b6_7393u64));
assert_eq!(pair.order_book[0].variant, PriceVariant::Simulated);
assert_eq!(pair.order_book[1].variant, PriceVariant::Interpolated);
}
#[test]
fn parse_price_levels_message_drops_unknown_variants_and_malformed_rungs() {
let raw = serde_json::json!({
"pamms": [{
"pamm": "0x0000000000000000000000000000000000000abc",
"pairs": [{
"tokenIn": "0x0000000000000000000000000000000000000011",
"tokenOut": "0x0000000000000000000000000000000000000022",
"orderBook": [
{ "amountIn": "0x1", "amountOut": "0x2", "variant": "Bogus" },
{ "amountIn": "0x3", "variant": "Simulated" },
{ "amountIn": "0x4", "amountOut": "0x5", "variant": "Simulated" },
],
}],
}],
});
let snapshot = parse_price_levels_message(&raw).unwrap();
let book = &snapshot.pamms[0].pairs[0].order_book;
assert_eq!(book.len(), 1);
assert_eq!(book[0].amount_in, U256::from(4u64));
}
#[test]
fn parse_price_levels_message_tolerates_missing_pamms() {
let snapshot = parse_price_levels_message(&serde_json::json!({ "slot": 1 })).unwrap();
assert!(snapshot.pamms.is_empty());
assert_eq!(snapshot.slot, Some(1));
}
#[test]
fn parse_titan_quote_parses_a_full_result() {
let raw = serde_json::json!({
"tokenIn": "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599",
"tokenOut": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
"amountIn": "0xde0b6b3a7640000",
"amountOut": "0xd09dc300",
"pamm": "0x5979458912f80b96d30d4220af8e2e4925a33320",
"router": "0x4ddf368080cd7946db5b459ad591c350158175e1",
"blockNumber": 25051224,
"slot": 14285824,
"timestamp": 1778253913749564761u64,
});
let quote = parse_titan_quote(&raw).unwrap();
assert_eq!(quote.amount_in, U256::exp10(18));
assert_eq!(quote.amount_out, U256::from(0xd09d_c300u64));
assert_eq!(
quote.pamm,
parse_address("0x5979458912f80b96d30d4220af8e2e4925a33320").unwrap()
);
assert_eq!(quote.block_number, Some(25_051_224));
assert_eq!(quote.timestamp_ns, Some(1_778_253_913_749_564_761));
}
#[test]
fn parse_titan_quote_fails_when_a_field_is_missing() {
let raw = serde_json::json!({
"tokenIn": "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599",
"tokenOut": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
"amountIn": "0x1",
});
assert!(matches!(parse_titan_quote(&raw), Err(Error::Prices(_))));
}
#[test]
fn extract_rpc_result_treats_null_error_as_success() {
let body = serde_json::json!({ "error": null, "result": { "pamms": [] } });
assert!(extract_rpc_result(&body).is_ok());
}
#[test]
fn extract_rpc_result_surfaces_a_real_error_object() {
let body = serde_json::json!({ "error": { "code": -32000, "message": "boom" } });
assert!(matches!(extract_rpc_result(&body), Err(Error::Prices(_))));
}
#[test]
fn extract_rpc_result_errors_without_result_or_error() {
let body = serde_json::json!({ "jsonrpc": "2.0", "id": 1 });
assert!(matches!(extract_rpc_result(&body), Err(Error::Prices(_))));
}
}