From 994dc2363dadde3e16ac504010b765d0d1aab717 Mon Sep 17 00:00:00 2001 From: Arda Nakisci Date: Wed, 18 Feb 2026 11:26:28 +0100 Subject: [PATCH 1/4] chore: update rain.orderbook submodule with trades-by-tx and subgraph fix Includes get_trades_for_transaction with polling, pub order_operations, and fix for nested transaction filter in SgTransactionTradesQuery. --- lib/rain.orderbook | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/rain.orderbook b/lib/rain.orderbook index 3284b53..ff9578c 160000 --- a/lib/rain.orderbook +++ b/lib/rain.orderbook @@ -1 +1 @@ -Subproject commit 3284b5321a223c0568d57b6c7f44c28ddb7469e9 +Subproject commit ff9578cfb61a42aa51205f4b2dcb6b81b5125c97 From 6777d80c44393d8f1b23670a9a34a8aca0c7c416 Mon Sep 17 00:00:00 2001 From: Arda Nakisci Date: Wed, 18 Feb 2026 11:26:32 +0100 Subject: [PATCH 2/4] error: add NotYetIndexed variant for 202 Accepted responses Maps to HTTP 202 with code NOT_YET_INDEXED, logged at info level since it represents an expected transient state during indexing. --- src/error.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/error.rs b/src/error.rs index 7a242a3..197019e 100644 --- a/src/error.rs +++ b/src/error.rs @@ -32,6 +32,8 @@ pub enum ApiError { Internal(String), #[error("Rate limited: {0}")] RateLimited(String), + #[error("Not yet indexed: {0}")] + NotYetIndexed(String), } impl<'r> Responder<'r, 'static> for ApiError { @@ -42,6 +44,7 @@ impl<'r> Responder<'r, 'static> for ApiError { ApiError::NotFound(msg) => (Status::NotFound, "NOT_FOUND", msg.clone()), ApiError::Internal(msg) => (Status::InternalServerError, "INTERNAL_ERROR", msg.clone()), ApiError::RateLimited(msg) => (Status::TooManyRequests, "RATE_LIMITED", msg.clone()), + ApiError::NotYetIndexed(msg) => (Status::Accepted, "NOT_YET_INDEXED", msg.clone()), }; let span = request_span_for(req); span.in_scope(|| { @@ -52,6 +55,13 @@ impl<'r> Responder<'r, 'static> for ApiError { error_message = %message, "request failed" ); + } else if matches!(self, ApiError::NotYetIndexed(_)) { + tracing::info!( + status = status.code, + code = %code, + error_message = %message, + "transaction not yet indexed" + ); } else { tracing::warn!( status = status.code, From 15e133a9bda17694abd40a543ec2c8b61cf5717c Mon Sep 17 00:00:00 2001 From: Arda Nakisci Date: Wed, 18 Feb 2026 11:26:38 +0100 Subject: [PATCH 3/4] trades: implement GET /v1/trades/tx/{tx_hash} endpoint Queries all configured orderbooks for trades in a transaction, looks up order owners by hash, computes IO ratios and totals using Float math. Distinguishes 404 (no trades) vs 202 (not yet indexed). Converts trades.rs to module directory with get_by_tx and get_by_address sub-modules following the swap module pattern. --- Cargo.lock | 1 + Cargo.toml | 1 + src/main.rs | 4 +- src/routes/trades.rs | 85 ------ src/routes/trades/get_by_address.rs | 45 ++++ src/routes/trades/get_by_tx.rs | 388 ++++++++++++++++++++++++++++ src/routes/trades/mod.rs | 63 +++++ 7 files changed, 500 insertions(+), 87 deletions(-) delete mode 100644 src/routes/trades.rs create mode 100644 src/routes/trades/get_by_address.rs create mode 100644 src/routes/trades/get_by_tx.rs create mode 100644 src/routes/trades/mod.rs diff --git a/Cargo.lock b/Cargo.lock index a8ea0dd..d4573fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8873,6 +8873,7 @@ dependencies = [ "async-trait", "base64 0.22.1", "clap", + "futures", "rain-math-float", "rain_orderbook_app_settings", "rain_orderbook_bindings", diff --git a/Cargo.toml b/Cargo.toml index 9e229c0..3d01861 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,4 +30,5 @@ rain_orderbook_common = { path = "lib/rain.orderbook/crates/common", default-fea rain_orderbook_app_settings = { path = "lib/rain.orderbook/crates/settings", default-features = false } rain_orderbook_bindings = { path = "lib/rain.orderbook/crates/bindings", default-features = false } rain-math-float = { path = "lib/rain.orderbook/lib/rain.interpreter/lib/rain.interpreter.interface/lib/rain.math.float/crates/float" } +futures = "0.3" wasm-bindgen = "=0.2.100" diff --git a/src/main.rs b/src/main.rs index a0af3c5..0ded2c1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -49,8 +49,8 @@ impl Modify for SecurityAddon { routes::order::post_order_cancel, routes::orders::get_orders_by_tx, routes::orders::get_orders_by_address, - routes::trades::get_trades_by_tx, - routes::trades::get_trades_by_address, + routes::trades::get_by_tx::get_trades_by_tx, + routes::trades::get_by_address::get_trades_by_address, ), components(), modifiers(&SecurityAddon), diff --git a/src/routes/trades.rs b/src/routes/trades.rs deleted file mode 100644 index e708d2d..0000000 --- a/src/routes/trades.rs +++ /dev/null @@ -1,85 +0,0 @@ -use crate::auth::AuthenticatedKey; -use crate::error::{ApiError, ApiErrorResponse}; -use crate::fairings::{GlobalRateLimit, TracingSpan}; -use crate::types::common::{ValidatedAddress, ValidatedFixedBytes}; -use crate::types::trades::{TradesByAddressResponse, TradesByTxResponse, TradesPaginationParams}; -use rocket::serde::json::Json; -use rocket::{Route, State}; -use tracing::Instrument; - -#[utoipa::path( - get, - path = "/v1/trades/tx/{tx_hash}", - tag = "Trades", - security(("basicAuth" = [])), - params( - ("tx_hash" = String, Path, description = "Transaction hash"), - ), - responses( - (status = 200, description = "Trades from transaction", body = TradesByTxResponse), - (status = 202, description = "Transaction not yet indexed", body = ApiErrorResponse), - (status = 401, description = "Unauthorized", body = ApiErrorResponse), - (status = 429, description = "Rate limited", body = ApiErrorResponse), - (status = 404, description = "Transaction not found", body = ApiErrorResponse), - (status = 500, description = "Internal server error", body = ApiErrorResponse), - ) -)] -#[get("/tx/")] -pub async fn get_trades_by_tx( - _global: GlobalRateLimit, - _key: AuthenticatedKey, - raindex: &State, - span: TracingSpan, - tx_hash: ValidatedFixedBytes, -) -> Result, ApiError> { - async move { - tracing::info!(tx_hash = ?tx_hash, "request received"); - raindex - .run_with_client(move |_client| async move { todo!() }) - .await - .map_err(ApiError::from)? - } - .instrument(span.0) - .await -} - -#[utoipa::path( - get, - path = "/v1/trades/{address}", - tag = "Trades", - security(("basicAuth" = [])), - params( - ("address" = String, Path, description = "Owner address"), - TradesPaginationParams, - ), - responses( - (status = 200, description = "Paginated list of trades", body = TradesByAddressResponse), - (status = 400, description = "Bad request", body = ApiErrorResponse), - (status = 401, description = "Unauthorized", body = ApiErrorResponse), - (status = 429, description = "Rate limited", body = ApiErrorResponse), - (status = 500, description = "Internal server error", body = ApiErrorResponse), - ) -)] -#[get("/
?", rank = 2)] -pub async fn get_trades_by_address( - _global: GlobalRateLimit, - _key: AuthenticatedKey, - raindex: &State, - span: TracingSpan, - address: ValidatedAddress, - params: TradesPaginationParams, -) -> Result, ApiError> { - async move { - tracing::info!(address = ?address, params = ?params, "request received"); - raindex - .run_with_client(move |_client| async move { todo!() }) - .await - .map_err(ApiError::from)? - } - .instrument(span.0) - .await -} - -pub fn routes() -> Vec { - rocket::routes![get_trades_by_tx, get_trades_by_address] -} diff --git a/src/routes/trades/get_by_address.rs b/src/routes/trades/get_by_address.rs new file mode 100644 index 0000000..66140ad --- /dev/null +++ b/src/routes/trades/get_by_address.rs @@ -0,0 +1,45 @@ +use crate::auth::AuthenticatedKey; +use crate::error::{ApiError, ApiErrorResponse}; +use crate::fairings::{GlobalRateLimit, TracingSpan}; +use crate::types::common::ValidatedAddress; +use crate::types::trades::{TradesByAddressResponse, TradesPaginationParams}; +use rocket::serde::json::Json; +use rocket::State; +use tracing::Instrument; + +#[utoipa::path( + get, + path = "/v1/trades/{address}", + tag = "Trades", + security(("basicAuth" = [])), + params( + ("address" = String, Path, description = "Owner address"), + TradesPaginationParams, + ), + responses( + (status = 200, description = "Paginated list of trades", body = TradesByAddressResponse), + (status = 400, description = "Bad request", body = ApiErrorResponse), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 429, description = "Rate limited", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ) +)] +#[get("/
?", rank = 2)] +pub async fn get_trades_by_address( + _global: GlobalRateLimit, + _key: AuthenticatedKey, + raindex: &State, + span: TracingSpan, + address: ValidatedAddress, + params: TradesPaginationParams, +) -> Result, ApiError> { + async move { + tracing::info!(address = ?address, params = ?params, "request received"); + raindex + .run_with_client(move |_client| async move { todo!() }) + .await + .map_err(ApiError::from)? + } + .instrument(span.0) + .await +} diff --git a/src/routes/trades/get_by_tx.rs b/src/routes/trades/get_by_tx.rs new file mode 100644 index 0000000..06cf73b --- /dev/null +++ b/src/routes/trades/get_by_tx.rs @@ -0,0 +1,388 @@ +use super::{RaindexTradesTxDataSource, TradesTxDataSource}; +use crate::auth::AuthenticatedKey; +use crate::error::{ApiError, ApiErrorResponse}; +use crate::fairings::{GlobalRateLimit, TracingSpan}; +use crate::routes::order::OrderDataSource; +use crate::types::common::ValidatedFixedBytes; +use crate::types::trades::{ + TradeByTxEntry, TradeRequest, TradeResult, TradesByTxResponse, TradesTotals, +}; +use alloy::primitives::{Address, B256}; +use rain_math_float::Float; +use rocket::serde::json::Json; +use rocket::State; +use std::collections::HashMap; +use std::str::FromStr; +use tracing::Instrument; + +#[utoipa::path( + get, + path = "/v1/trades/tx/{tx_hash}", + tag = "Trades", + security(("basicAuth" = [])), + params( + ("tx_hash" = String, Path, description = "Transaction hash"), + ), + responses( + (status = 200, description = "Trades from transaction", body = TradesByTxResponse), + (status = 202, description = "Transaction not yet indexed", body = ApiErrorResponse), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 429, description = "Rate limited", body = ApiErrorResponse), + (status = 404, description = "Transaction not found", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ) +)] +#[get("/tx/")] +pub async fn get_trades_by_tx( + _global: GlobalRateLimit, + _key: AuthenticatedKey, + raindex: &State, + span: TracingSpan, + tx_hash: ValidatedFixedBytes, +) -> Result, ApiError> { + async move { + tracing::info!(tx_hash = ?tx_hash, "request received"); + raindex + .run_with_client(move |client| async move { + let trades_ds = RaindexTradesTxDataSource { client: &client }; + let order_ds = crate::routes::order::RaindexOrderDataSource { client: &client }; + process_get_trades_by_tx(&trades_ds, &order_ds, tx_hash.0).await + }) + .await + .map_err(ApiError::from)? + } + .instrument(span.0) + .await +} + +pub(super) async fn process_get_trades_by_tx( + trades_ds: &dyn TradesTxDataSource, + order_ds: &dyn OrderDataSource, + tx_hash: B256, +) -> Result, ApiError> { + let trades = trades_ds.get_trades_by_tx(tx_hash).await?; + + if trades.is_empty() { + return Err(ApiError::NotFound( + "transaction has no associated trades".into(), + )); + } + + let first_tx = trades[0].transaction(); + let block_number: u64 = first_tx.block_number().try_into().map_err(|_| { + tracing::error!("block number does not fit in u64"); + ApiError::Internal("block number overflow".into()) + })?; + let timestamp: u64 = first_tx.timestamp().try_into().map_err(|_| { + tracing::error!("timestamp does not fit in u64"); + ApiError::Internal("timestamp overflow".into()) + })?; + let sender: Address = first_tx.from(); + + let unique_hashes: Vec = { + let mut seen = std::collections::HashSet::new(); + trades + .iter() + .map(|t| { + B256::from_str(&t.order_hash().to_string()).map_err(|e| { + tracing::error!(error = %e, "failed to parse order hash"); + ApiError::Internal("failed to parse order hash".into()) + }) + }) + .collect::, _>>()? + .into_iter() + .filter(|h| seen.insert(*h)) + .collect() + }; + + let order_results: Vec> = + futures::future::join_all(unique_hashes.iter().map(|hash| { + let hash = *hash; + async move { + let orders = order_ds.get_orders_by_hash(hash).await?; + Ok((hash, orders.into_iter().next())) + } + })) + .await; + + let order_cache: HashMap< + String, + Option, + > = order_results + .into_iter() + .collect::, _>>()? + .into_iter() + .map(|(hash, order)| (hash.to_string(), order)) + .collect(); + + let mut trade_entries = Vec::new(); + let mut total_input = Float::parse("0".to_string()).map_err(|e| { + tracing::error!(error = %e, "float parse error"); + ApiError::Internal("float parse error".into()) + })?; + let mut total_output = Float::parse("0".to_string()).map_err(|e| { + tracing::error!(error = %e, "float parse error"); + ApiError::Internal("float parse error".into()) + })?; + + for trade in &trades { + let order_hash_str = trade.order_hash().to_string(); + let order_owner = order_cache + .get(&order_hash_str) + .and_then(|o| o.as_ref()) + .map(|o| o.owner()) + .ok_or_else(|| { + tracing::error!(order_hash = %order_hash_str, "order not found for trade"); + ApiError::Internal("order not found for trade".into()) + })?; + + let input_vc = trade.input_vault_balance_change(); + let output_vc = trade.output_vault_balance_change(); + + total_input = (total_input + input_vc.amount()).map_err(|e| { + tracing::error!(error = %e, "float add error"); + ApiError::Internal("float arithmetic error".into()) + })?; + total_output = (total_output + output_vc.amount()).map_err(|e| { + tracing::error!(error = %e, "float add error"); + ApiError::Internal("float arithmetic error".into()) + })?; + + let zero = Float::parse("0".to_string()).map_err(|e| { + tracing::error!(error = %e, "float parse error"); + ApiError::Internal("float parse error".into()) + })?; + let abs_output = (zero - output_vc.amount()).map_err(|e| { + tracing::error!(error = %e, "float sub error"); + ApiError::Internal("float arithmetic error".into()) + })?; + let actual_io_ratio = (input_vc.amount() / abs_output) + .map_err(|e| { + tracing::error!(error = %e, "float div error"); + ApiError::Internal("float arithmetic error".into()) + })? + .format() + .map_err(|e| { + tracing::error!(error = %e, "float format error"); + ApiError::Internal("float format error".into()) + })?; + + let order_hash_fixed = B256::from_str(&order_hash_str).map_err(|e| { + tracing::error!(error = %e, order_hash = %order_hash_str, "failed to parse order hash"); + ApiError::Internal("failed to parse order hash".into()) + })?; + + trade_entries.push(TradeByTxEntry { + order_hash: order_hash_fixed, + order_owner, + request: TradeRequest { + input_token: input_vc.token().address(), + output_token: output_vc.token().address(), + maximum_input: input_vc.formatted_amount(), + maximum_io_ratio: actual_io_ratio.clone(), + }, + result: TradeResult { + input_amount: input_vc.formatted_amount(), + output_amount: output_vc.formatted_amount(), + actual_io_ratio, + }, + }); + } + + let zero = Float::parse("0".to_string()).map_err(|e| { + tracing::error!(error = %e, "float parse error"); + ApiError::Internal("float parse error".into()) + })?; + let abs_total_output = (zero - total_output).map_err(|e| { + tracing::error!(error = %e, "float sub error"); + ApiError::Internal("float arithmetic error".into()) + })?; + let average_io_ratio = (total_input / abs_total_output) + .map_err(|e| { + tracing::error!(error = %e, "float div error"); + ApiError::Internal("float arithmetic error".into()) + })? + .format() + .map_err(|e| { + tracing::error!(error = %e, "float format error"); + ApiError::Internal("float format error".into()) + })?; + let total_input_amount = total_input.format().map_err(|e| { + tracing::error!(error = %e, "float format error"); + ApiError::Internal("float format error".into()) + })?; + let total_output_amount = total_output.format().map_err(|e| { + tracing::error!(error = %e, "float format error"); + ApiError::Internal("float format error".into()) + })?; + + Ok(Json(TradesByTxResponse { + tx_hash, + block_number, + timestamp, + sender, + trades: trade_entries, + totals: TradesTotals { + total_input_amount, + total_output_amount, + average_io_ratio, + }, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::ApiError; + use crate::routes::order::test_fixtures::*; + use crate::test_helpers::{ + basic_auth_header, mock_invalid_raindex_config, seed_api_key, TestClientBuilder, + }; + use alloy::primitives::{address, Bytes}; + use async_trait::async_trait; + use rain_orderbook_common::raindex_client::trades::RaindexTrade; + use rocket::http::{Header, Status}; + + struct MockTradesTxDataSource { + result: Result, ApiError>, + } + + #[async_trait(?Send)] + impl TradesTxDataSource for MockTradesTxDataSource { + async fn get_trades_by_tx(&self, _tx_hash: B256) -> Result, ApiError> { + match &self.result { + Ok(trades) => Ok(trades.clone()), + Err(e) => Err(e.clone()), + } + } + } + + #[rocket::async_test] + async fn test_process_success() { + let trades_ds = MockTradesTxDataSource { + result: Ok(vec![mock_trade()]), + }; + let order_ds = MockOrderDataSource { + orders: Ok(vec![mock_order()]), + trades: vec![], + quotes: vec![], + calldata: Ok(Bytes::new()), + }; + let result = process_get_trades_by_tx( + &trades_ds, + &order_ds, + "0x0000000000000000000000000000000000000000000000000000000000000088" + .parse() + .unwrap(), + ) + .await + .unwrap(); + + let response = result.into_inner(); + assert_eq!(response.trades.len(), 1); + assert_eq!( + response.sender, + address!("0000000000000000000000000000000000000002") + ); + assert_eq!(response.block_number, 100); + assert_eq!(response.timestamp, 1700001000); + assert_eq!( + response.trades[0].order_owner, + address!("0000000000000000000000000000000000000001") + ); + } + + #[rocket::async_test] + async fn test_process_tx_not_found() { + let trades_ds = MockTradesTxDataSource { result: Ok(vec![]) }; + let order_ds = MockOrderDataSource { + orders: Ok(vec![]), + trades: vec![], + quotes: vec![], + calldata: Ok(Bytes::new()), + }; + let result = process_get_trades_by_tx( + &trades_ds, + &order_ds, + "0x0000000000000000000000000000000000000000000000000000000000000001" + .parse() + .unwrap(), + ) + .await; + assert!(matches!(result, Err(ApiError::NotFound(_)))); + } + + #[rocket::async_test] + async fn test_process_tx_not_indexed() { + let trades_ds = MockTradesTxDataSource { + result: Err(ApiError::NotYetIndexed("not indexed".into())), + }; + let order_ds = MockOrderDataSource { + orders: Ok(vec![]), + trades: vec![], + quotes: vec![], + calldata: Ok(Bytes::new()), + }; + let result = process_get_trades_by_tx( + &trades_ds, + &order_ds, + "0x0000000000000000000000000000000000000000000000000000000000000001" + .parse() + .unwrap(), + ) + .await; + assert!(matches!(result, Err(ApiError::NotYetIndexed(_)))); + } + + #[rocket::async_test] + async fn test_process_query_failure() { + let trades_ds = MockTradesTxDataSource { + result: Err(ApiError::Internal("subgraph error".into())), + }; + let order_ds = MockOrderDataSource { + orders: Ok(vec![]), + trades: vec![], + quotes: vec![], + calldata: Ok(Bytes::new()), + }; + let result = process_get_trades_by_tx( + &trades_ds, + &order_ds, + "0x0000000000000000000000000000000000000000000000000000000000000001" + .parse() + .unwrap(), + ) + .await; + assert!(matches!(result, Err(ApiError::Internal(_)))); + } + + #[rocket::async_test] + async fn test_get_trades_by_tx_401_without_auth() { + let client = TestClientBuilder::new().build().await; + let response = client + .get("/v1/trades/tx/0x0000000000000000000000000000000000000000000000000000000000000088") + .dispatch() + .await; + assert_eq!(response.status(), Status::Unauthorized); + } + + #[rocket::async_test] + async fn test_get_trades_by_tx_500_on_bad_raindex_config() { + let config = mock_invalid_raindex_config().await; + let client = TestClientBuilder::new() + .raindex_config(config) + .build() + .await; + let (key_id, secret) = seed_api_key(&client).await; + let header = basic_auth_header(&key_id, &secret); + let response = client + .get("/v1/trades/tx/0x0000000000000000000000000000000000000000000000000000000000000088") + .header(Header::new("Authorization", header)) + .dispatch() + .await; + assert_eq!(response.status(), Status::InternalServerError); + let body: serde_json::Value = + serde_json::from_str(&response.into_string().await.unwrap()).unwrap(); + assert_eq!(body["error"]["code"], "INTERNAL_ERROR"); + } +} diff --git a/src/routes/trades/mod.rs b/src/routes/trades/mod.rs new file mode 100644 index 0000000..4de9d42 --- /dev/null +++ b/src/routes/trades/mod.rs @@ -0,0 +1,63 @@ +pub(crate) mod get_by_address; +pub(crate) mod get_by_tx; + +use crate::error::ApiError; +use alloy::primitives::B256; +use async_trait::async_trait; +use rain_orderbook_common::raindex_client::trades::RaindexTrade; +use rain_orderbook_common::raindex_client::{RaindexClient, RaindexError}; +use rocket::Route; + +#[async_trait(?Send)] +pub(crate) trait TradesTxDataSource { + async fn get_trades_by_tx(&self, tx_hash: B256) -> Result, ApiError>; +} + +pub(crate) struct RaindexTradesTxDataSource<'a> { + pub client: &'a RaindexClient, +} + +#[async_trait(?Send)] +impl TradesTxDataSource for RaindexTradesTxDataSource<'_> { + async fn get_trades_by_tx(&self, tx_hash: B256) -> Result, ApiError> { + let orderbooks = self.client.get_all_orderbooks().map_err(|e| { + tracing::error!(error = %e, "failed to get orderbooks"); + ApiError::Internal("failed to get orderbooks".into()) + })?; + + let mut all_trades: Vec = Vec::new(); + for ob_cfg in orderbooks.values() { + let chain_id = ob_cfg.network.chain_id; + let address = ob_cfg.address; + match self + .client + .get_trades_for_transaction(chain_id, address, tx_hash, None, None) + .await + { + Ok(trades) => all_trades.extend(trades), + Err(RaindexError::TradesIndexingTimeout { tx_hash, attempts }) => { + tracing::info!( + tx_hash = %tx_hash, + attempts = attempts, + "transaction not yet indexed" + ); + return Err(ApiError::NotYetIndexed(format!( + "transaction {tx_hash:#x} not yet indexed after {attempts} attempts" + ))); + } + Err(e) => { + tracing::error!(error = %e, "failed to query trades for transaction"); + return Err(ApiError::Internal("failed to query trades".into())); + } + } + } + Ok(all_trades) + } +} + +pub fn routes() -> Vec { + rocket::routes![ + get_by_tx::get_trades_by_tx, + get_by_address::get_trades_by_address + ] +} From 5ee9254a9dc643185c4ad25291f081d69e03841b Mon Sep 17 00:00:00 2001 From: Arda Nakisci Date: Wed, 18 Feb 2026 11:26:43 +0100 Subject: [PATCH 4/4] docs: add address! macro rule to AGENTS.md --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index a5bd144..35486a6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,3 +18,4 @@ - All API errors must go through the `ApiError` enum, never return raw status codes - Keep OpenAPI annotations (`#[utoipa::path(...)]`) in sync when adding or modifying routes - Do not commit `.env` or secrets; use `.env.example` for documenting env vars +- Use the `address!` macro from `alloy::primitives` for creating `Address` constants instead of parsing from strings