diff --git a/crates/tx-cache/Cargo.toml b/crates/tx-cache/Cargo.toml index 2271e66..3be9101 100644 --- a/crates/tx-cache/Cargo.toml +++ b/crates/tx-cache/Cargo.toml @@ -21,4 +21,9 @@ eyre.workspace = true reqwest.workspace = true serde = { workspace = true, features = ["derive"] } tracing.workspace = true -uuid = { workspace = true, features = ["serde"] } \ No newline at end of file +uuid = { workspace = true, features = ["serde"] } + +[dev-dependencies] +serde_urlencoded = "0.7.1" +uuid = { workspace = true, features = ["serde", "v4"] } +serde_json.workspace = true \ No newline at end of file diff --git a/crates/tx-cache/src/client.rs b/crates/tx-cache/src/client.rs index 80c77be..0bae956 100644 --- a/crates/tx-cache/src/client.rs +++ b/crates/tx-cache/src/client.rs @@ -1,6 +1,6 @@ use crate::types::{ - TxCacheOrdersResponse, TxCacheSendBundleResponse, TxCacheSendTransactionResponse, - TxCacheTransactionsResponse, + CacheObject, CacheResponse, OrderKey, TxCacheOrdersResponse, TxCacheSendBundleResponse, + TxCacheSendTransactionResponse, TxCacheTransactionsResponse, TxKey, }; use alloy::consensus::TxEnvelope; use eyre::Error; @@ -93,22 +93,21 @@ impl TxCache { self.client.post(url).json(&obj).send().await?.error_for_status().map_err(Into::into) } - async fn get_inner(&self, join: &'static str) -> Result + async fn get_inner(&self, join: &'static str, query: Option) -> Result where - T: DeserializeOwned, + T: DeserializeOwned + CacheObject, { - // Append the path to the URL. let url = self .url .join(join) .inspect_err(|e| warn!(%e, "Failed to join URL. Not querying transaction cache."))?; - // Get the result. self.client .get(url) + .query(&query) .send() .await - .inspect_err(|e| warn!(%e, "Failed to get object from transaction cache"))? + .inspect_err(|e| warn!(%e, "Failed to get object from transaction cache."))? .json::() .await .map_err(Into::into) @@ -140,17 +139,19 @@ impl TxCache { /// Get transactions from the URL. #[instrument(skip_all)] - pub async fn get_transactions(&self) -> Result, Error> { - let response: TxCacheTransactionsResponse = - self.get_inner::(TRANSACTIONS).await?; - Ok(response.transactions) + pub async fn get_transactions( + &self, + query: Option, + ) -> Result, Error> { + self.get_inner(TRANSACTIONS, query).await } /// Get signed orders from the URL. #[instrument(skip_all)] - pub async fn get_orders(&self) -> Result, Error> { - let response: TxCacheOrdersResponse = - self.get_inner::(ORDERS).await?; - Ok(response.orders) + pub async fn get_orders( + &self, + query: Option, + ) -> Result, Error> { + self.get_inner(ORDERS, query).await } } diff --git a/crates/tx-cache/src/types.rs b/crates/tx-cache/src/types.rs index 0d700b5..089e253 100644 --- a/crates/tx-cache/src/types.rs +++ b/crates/tx-cache/src/types.rs @@ -1,12 +1,102 @@ //! The endpoints for the transaction cache. use alloy::{consensus::TxEnvelope, primitives::B256}; -use serde::{Deserialize, Serialize}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; use signet_bundle::SignetEthBundle; use signet_types::SignedOrder; +use uuid::Uuid; + +/// A trait for types that can be used as a cache object. +pub trait CacheObject { + /// The cursor key type for the cache object. + type Key: Serialize + DeserializeOwned; +} + +/// A response from the transaction cache, containing an item. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CacheResponse { + /// The response. + #[serde(flatten)] + inner: T, + /// The next cursor for pagination, if any. + #[serde(default, skip_serializing_if = "Option::is_none")] + next_cursor: Option, +} + +impl CacheObject for CacheResponse { + type Key = T::Key; +} + +impl CacheResponse { + /// Create a new paginated response from a list of items and a pagination info. + pub const fn paginated(inner: T, pagination: T::Key) -> Self { + Self { inner, next_cursor: Some(pagination) } + } + + /// Create a new unpaginated response from a list of items. + pub const fn unpaginated(inner: T) -> Self { + Self { inner, next_cursor: None } + } + + /// Return a reference to the inner value. + pub const fn inner(&self) -> &T { + match self { + Self { inner, .. } => inner, + } + } + + /// Return a mutable reference to the inner value. + pub const fn inner_mut(&mut self) -> &mut T { + match self { + Self { inner, .. } => inner, + } + } + + /// Return the next cursor for pagination, if any. + pub const fn next_cursor(&self) -> Option<&T::Key> { + match self { + Self { next_cursor, .. } => next_cursor.as_ref(), + } + } + + /// Check if the response has more items to fetch. + pub const fn has_more(&self) -> bool { + self.next_cursor().is_some() + } + + /// Check if the response is paginated. + pub const fn is_paginated(&self) -> bool { + self.next_cursor.is_some() + } + + /// Check if the response is unpaginated. + pub const fn is_unpaginated(&self) -> bool { + self.next_cursor.is_none() + } + + /// Get the inner value. + pub fn into_inner(self) -> T { + match self { + Self { inner, .. } => inner, + } + } + + /// Consume the response and return the parts. + pub fn into_parts(self) -> (T, Option) { + match self { + Self { inner, next_cursor } => (inner, next_cursor), + } + } + + /// Consume the response and return the next cursor for pagination, if any. + pub fn into_next_cursor(self) -> Option { + self.into_parts().1 + } +} /// A bundle response from the transaction cache, containing a UUID and a /// [`SignetEthBundle`]. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct TxCacheBundle { /// The bundle id (a UUID) pub id: uuid::Uuid, @@ -48,7 +138,7 @@ impl TxCacheBundle { } /// A response from the transaction cache, containing a single bundle. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct TxCacheBundleResponse { /// The bundle. pub bundle: TxCacheBundle, @@ -66,6 +156,10 @@ impl From for TxCacheBundle { } } +impl CacheObject for TxCacheBundleResponse { + type Key = BundleKey; +} + impl TxCacheBundleResponse { /// Create a new bundle response from a bundle. pub const fn new(bundle: TxCacheBundle) -> Self { @@ -86,7 +180,7 @@ impl TxCacheBundleResponse { } /// Response from the transaction cache `bundles` endpoint, containing a list of bundles. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct TxCacheBundlesResponse { /// the list of bundles pub bundles: Vec, @@ -104,6 +198,10 @@ impl From for Vec { } } +impl CacheObject for TxCacheBundlesResponse { + type Key = BundleKey; +} + impl TxCacheBundlesResponse { /// Create a new bundle response from a list of bundles. pub const fn new(bundles: Vec) -> Self { @@ -113,7 +211,7 @@ impl TxCacheBundlesResponse { /// Create a new bundle response from a list of bundles. #[deprecated = "Use `From::from` instead, `Self::new` in const contexts"] pub const fn from_bundles(bundles: Vec) -> Self { - Self::new(bundles) + Self { bundles } } /// Convert the bundle response to a list of [`SignetEthBundle`]. @@ -121,10 +219,15 @@ impl TxCacheBundlesResponse { pub fn into_bundles(self) -> Vec { self.bundles } + + /// Check if the response is empty (has no bundles). + pub fn is_empty(&self) -> bool { + self.bundles.is_empty() + } } /// Represents a response to successfully adding or updating a bundle in the transaction cache. -#[derive(Debug, Copy, Clone, Serialize, Deserialize)] +#[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct TxCacheSendBundleResponse { /// The bundle id (a UUID) pub id: uuid::Uuid, @@ -149,8 +252,12 @@ impl From for uuid::Uuid { } } +impl CacheObject for TxCacheSendBundleResponse { + type Key = BundleKey; +} + /// Response from the transaction cache `transactions` endpoint, containing a list of transactions. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct TxCacheTransactionsResponse { /// The list of transactions. pub transactions: Vec, @@ -168,6 +275,10 @@ impl From for Vec { } } +impl CacheObject for TxCacheTransactionsResponse { + type Key = TxKey; +} + impl TxCacheTransactionsResponse { /// Instantiate a new transaction response from a list of transactions. pub const fn new(transactions: Vec) -> Self { @@ -177,7 +288,7 @@ impl TxCacheTransactionsResponse { /// Create a new transaction response from a list of transactions. #[deprecated = "Use `From::from` instead, or `Self::new` in const contexts"] pub const fn from_transactions(transactions: Vec) -> Self { - Self::new(transactions) + Self { transactions } } /// Convert the transaction response to a list of [`TxEnvelope`]. @@ -185,10 +296,15 @@ impl TxCacheTransactionsResponse { pub fn into_transactions(self) -> Vec { self.transactions } + + /// Check if the response is empty (has no transactions). + pub fn is_empty(&self) -> bool { + self.transactions.is_empty() + } } /// Response from the transaction cache to successfully adding a transaction. -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] pub struct TxCacheSendTransactionResponse { /// The transaction hash pub tx_hash: B256, @@ -206,6 +322,10 @@ impl From for B256 { } } +impl CacheObject for TxCacheSendTransactionResponse { + type Key = TxKey; +} + impl TxCacheSendTransactionResponse { /// Create a new transaction response from a transaction hash. pub const fn new(tx_hash: B256) -> Self { @@ -226,7 +346,7 @@ impl TxCacheSendTransactionResponse { } /// Response from the transaction cache `orders` endpoint, containing a list of signed orders. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct TxCacheOrdersResponse { /// The list of signed orders. pub orders: Vec, @@ -244,6 +364,10 @@ impl From for Vec { } } +impl CacheObject for TxCacheOrdersResponse { + type Key = OrderKey; +} + impl TxCacheOrdersResponse { /// Create a new order response from a list of orders. pub const fn new(orders: Vec) -> Self { @@ -262,3 +386,204 @@ impl TxCacheOrdersResponse { self.orders } } + +/// Response from the transaction cache to successfully adding an order. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub struct TxCacheSendOrderResponse { + /// The order id + pub id: B256, +} + +impl From for TxCacheSendOrderResponse { + fn from(id: B256) -> Self { + Self { id } + } +} + +impl From for B256 { + fn from(response: TxCacheSendOrderResponse) -> Self { + response.id + } +} + +impl CacheObject for TxCacheSendOrderResponse { + type Key = OrderKey; +} + +impl TxCacheSendOrderResponse { + /// Create a new order response from an order id. + pub const fn new(id: B256) -> Self { + Self { id } + } +} + +/// The query object keys for the transaction GET endpoint. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct TxKey { + /// The transaction hash + pub txn_hash: B256, + /// The transaction score + pub score: u64, + /// The global transaction score key + pub global_transaction_score_key: String, +} + +/// The query object keys for the bundle GET endpoint. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BundleKey { + /// The bundle id + pub id: Uuid, + /// The bundle score + pub score: u64, + /// The global bundle score key + pub global_bundle_score_key: String, +} + +/// The query object keys for the order GET endpoint. +#[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct OrderKey { + /// The order id + pub id: B256, +} + +#[cfg(test)] +mod tests { + use super::*; + use std::str::FromStr; + + fn dummy_bundle_with_id(id: Uuid) -> TxCacheBundle { + TxCacheBundle { + id, + bundle: SignetEthBundle { + bundle: alloy::rpc::types::mev::EthSendBundle { + txs: vec![], + block_number: 0, + min_timestamp: None, + max_timestamp: None, + reverting_tx_hashes: vec![], + replacement_uuid: Some(id.to_string()), + dropping_tx_hashes: vec![], + refund_percent: None, + refund_recipient: None, + refund_tx_hashes: vec![], + extra_fields: Default::default(), + }, + host_txs: vec![], + }, + } + } + + #[test] + fn test_unpaginated_cache_response_deser() { + let cache_response = + CacheResponse::unpaginated(TxCacheTransactionsResponse { transactions: vec![] }); + let expected_json = r#"{"transactions":[]}"#; + let serialized = serde_json::to_string(&cache_response).unwrap(); + assert_eq!(serialized, expected_json); + let deserialized = + serde_json::from_str::>(&serialized) + .unwrap(); + assert_eq!(deserialized, cache_response); + } + + #[test] + fn test_paginated_cache_response_deser() { + let cache_response = CacheResponse::paginated( + TxCacheTransactionsResponse { transactions: vec![] }, + TxKey { + txn_hash: B256::repeat_byte(0xaa), + score: 100, + global_transaction_score_key: "gtsk".to_string(), + }, + ); + let expected_json = r#"{"transactions":[],"nextCursor":{"txnHash":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","score":100,"globalTransactionScoreKey":"gtsk"}}"#; + let serialized = serde_json::to_string(&cache_response).unwrap(); + assert_eq!(serialized, expected_json); + let deserialized = + serde_json::from_str::>(expected_json) + .unwrap(); + assert_eq!(deserialized, cache_response); + } + + // `serde_json` should be able to deserialize the old format, regardless if there's pagination information on the response. + // This mimics the behavior of the types pre-pagination. + #[test] + fn test_backwards_compatibility_cache_response_deser() { + let expected_json = r#"{"transactions":[],"nextCursor":{"txnHash":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","score":100,"globalTransactionScoreKey":"gtsk"}}"#; + let deserialized = + serde_json::from_str::(expected_json).unwrap(); + assert_eq!(deserialized, TxCacheTransactionsResponse { transactions: vec![] }); + } + + // `serde_json` should be able to deserialize the old format, regardless if there's pagination information on the response. + // This mimics the behavior of the types pre-pagination. + #[test] + fn test_backwards_compatibility_cache_bundle_response_deser() { + let expected_json = r#"{"bundles":[{"id":"5932d4bb-58d9-41a9-851d-8dd7f04ccc33","bundle":{"txs":[],"blockNumber":"0x0","replacementUuid":"5932d4bb-58d9-41a9-851d-8dd7f04ccc33"}}]}"#; + let uuid = Uuid::from_str("5932d4bb-58d9-41a9-851d-8dd7f04ccc33").unwrap(); + + let deserialized = serde_json::from_str::(expected_json).unwrap(); + + assert_eq!( + deserialized, + TxCacheBundlesResponse { bundles: vec![dummy_bundle_with_id(uuid)] } + ); + } + + // `serde_json` should be able to deserialize the old format, regardless if there's pagination information on the response. + // This mimics the behavior of the types pre-pagination. + #[test] + fn test_backwards_compatibility_cache_order_response_deser() { + let expected_json = r#"{"orders":[{"permit":{"permitted":[{"token":"0x0b8bc5e60ee10957e0d1a0d95598fa63e65605e2","amount":"0xf4240"}],"nonce":"0x637253c1eb651","deadline":"0x6846fde6"},"owner":"0x492e9c316f073fe4de9d665221568cdad1a7e95b","signature":"0x73e31a7c80f02840c4e0671230c408a5cbc7cddefc780db4dd102eed8e87c5740fc89944eb8e5756edd368ed755415ed090b043d1740ee6869c20cb1676329621c","outputs":[{"token":"0x885f8db528dc8a38aa3ddad9d3f619746b4a6a81","amount":"0xf4240","recipient":"0x492e9c316f073fe4de9d665221568cdad1a7e95b","chainId":3151908}]}], "id":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}"#; + let _ = serde_json::from_str::(expected_json).unwrap(); + } + + #[test] + fn test_unpaginated_cache_bundle_response_deser() { + let cache_response = CacheResponse::unpaginated(TxCacheBundlesResponse { + bundles: vec![dummy_bundle_with_id( + Uuid::from_str("5932d4bb-58d9-41a9-851d-8dd7f04ccc33").unwrap(), + )], + }); + let expected_json = r#"{"bundles":[{"id":"5932d4bb-58d9-41a9-851d-8dd7f04ccc33","bundle":{"txs":[],"blockNumber":"0x0","replacementUuid":"5932d4bb-58d9-41a9-851d-8dd7f04ccc33"}}]}"#; + let serialized = serde_json::to_string(&cache_response).unwrap(); + assert_eq!(serialized, expected_json); + let deserialized = + serde_json::from_str::>(expected_json).unwrap(); + assert_eq!(deserialized, cache_response); + } + + #[test] + fn test_paginated_cache_bundle_response_deser() { + let uuid = Uuid::from_str("5932d4bb-58d9-41a9-851d-8dd7f04ccc33").unwrap(); + + let cache_response = CacheResponse::paginated( + TxCacheBundlesResponse { bundles: vec![dummy_bundle_with_id(uuid)] }, + BundleKey { id: uuid, score: 100, global_bundle_score_key: "gbsk".to_string() }, + ); + let expected_json = r#"{"bundles":[{"id":"5932d4bb-58d9-41a9-851d-8dd7f04ccc33","bundle":{"txs":[],"blockNumber":"0x0","replacementUuid":"5932d4bb-58d9-41a9-851d-8dd7f04ccc33"}}],"nextCursor":{"id":"5932d4bb-58d9-41a9-851d-8dd7f04ccc33","score":100,"globalBundleScoreKey":"gbsk"}}"#; + let serialized = serde_json::to_string(&cache_response).unwrap(); + assert_eq!(serialized, expected_json); + let deserialized = + serde_json::from_str::>(expected_json).unwrap(); + assert_eq!(deserialized, cache_response); + } + + #[test] + fn test_pagination_params_simple_deser() { + let tx_key = TxKey { + txn_hash: B256::repeat_byte(0xaa), + score: 100, + global_transaction_score_key: "gtsk".to_string(), + }; + let params = tx_key.clone(); + let empty_params: Option = None; + + let serialized = serde_urlencoded::to_string(¶ms).unwrap(); + let empty_serialized = serde_urlencoded::to_string(&empty_params).unwrap(); + assert_eq!(serialized, "txnHash=0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&score=100&globalTransactionScoreKey=gtsk"); + assert_eq!(empty_serialized, ""); + } +}