Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
c5928c0
Add v1/orders POST endpoint to get orders in batches
m-sz Jan 13, 2026
e5d0199
Merge branch 'main' into get-orders-by-uid
m-sz Jan 16, 2026
b768ac8
fmt
m-sz Jan 16, 2026
f897dcd
clippy
m-sz Jan 16, 2026
319f5d1
Merge branch 'main' into get-orders-by-uid
m-sz Jan 20, 2026
f4be44b
Merge branch 'main' into get-orders-by-uid
m-sz Jan 20, 2026
0c7b052
fmt
m-sz Jan 20, 2026
6af9b50
Merge branch 'main' into get-orders-by-uid
m-sz Jan 22, 2026
01eccfe
Change the bulk endpoint to /v1/orders/lookup to avoid collision
m-sz Jan 23, 2026
9b5d618
Fix unit tests
m-sz Jan 23, 2026
35c2ce7
Add negative test case
m-sz Jan 23, 2026
f26a539
Merge branch 'main' into get-orders-by-uid
m-sz Jan 30, 2026
508aa5b
Use bulk query for many orders
m-sz Jan 30, 2026
cff1a94
Merge branch 'main' into get-orders-by-uid
m-sz Feb 5, 2026
17a26c3
Clippy fix
m-sz Feb 5, 2026
3dbd193
fmt and clippy
m-sz Feb 5, 2026
7c11725
Merge branch 'main' into get-orders-by-uid
m-sz Feb 5, 2026
5de3e9c
fmt
m-sz Feb 5, 2026
c0dd2d9
Merge branch 'main' into get-orders-by-uid
m-sz Feb 17, 2026
e6d7bc9
Merge branch 'main' into get-orders-by-uid
m-sz Feb 17, 2026
373e9f1
Fix the get-orders-by-uid end to end test case
m-sz Feb 17, 2026
de0bc19
Fix unit test
m-sz Feb 17, 2026
67e8fc8
Merge branch 'main' into get-orders-by-uid
m-sz Feb 18, 2026
babc062
Merge branch 'main' into get-orders-by-uid
m-sz Feb 18, 2026
d8b161c
Merge branch 'main' into get-orders-by-uid
m-sz Feb 18, 2026
091c1f6
Merge branch 'main' into get-orders-by-uid
m-sz Feb 19, 2026
648e414
Update crates/orderbook/src/api/get_orders_by_uid.rs
m-sz Feb 19, 2026
88cfce9
Update crates/orderbook/src/api/get_orders_by_uid.rs
m-sz Feb 19, 2026
b450ab6
Merge branch 'main' into get-orders-by-uid
m-sz Feb 19, 2026
eeb9f86
Fix compilation
m-sz Feb 19, 2026
9945081
Change output format to JSONL
m-sz Feb 19, 2026
73f61de
fmt
m-sz Feb 19, 2026
3a0377f
Merge branch 'main' into get-orders-by-uid
m-sz Feb 19, 2026
080bcd0
expect(clippy::large_enum_variant) on OrderResultEntry
m-sz Feb 19, 2026
75ce8f0
unit test fix
m-sz Feb 19, 2026
9cfbf54
Merge branch 'main' into get-orders-by-uid
m-sz Feb 20, 2026
87a1b6e
Reimplement eager order collection
m-sz Feb 20, 2026
44026f6
Merge branch 'main' into get-orders-by-uid
m-sz Feb 23, 2026
2cd718b
Concurrently query the database for orders and jit orders
m-sz Feb 23, 2026
2bec199
Merge branch 'main' into get-orders-by-uid
m-sz Feb 23, 2026
7186254
Merge branch 'main' into get-orders-by-uid
m-sz Feb 23, 2026
c36972b
Merge branch 'main' into get-orders-by-uid
m-sz Feb 23, 2026
1799de4
Update crates/database/src/jit_orders.rs
m-sz Feb 24, 2026
fe9a493
Rework errors and status codes
m-sz Feb 24, 2026
c6fae97
Merge branch 'main' into get-orders-by-uid
m-sz Feb 24, 2026
fa734bf
Fix unit tests
m-sz Feb 24, 2026
9015c47
Merge branch 'main' into get-orders-by-uid
m-sz Feb 24, 2026
1edd7bc
Update openapi spec
m-sz Feb 24, 2026
c0284e1
Update crates/orderbook/openapi.yml
m-sz Feb 25, 2026
b4f168f
Error handling improvements
m-sz Feb 25, 2026
28dbfe9
fix unit test
m-sz Feb 25, 2026
5dd9627
Merge branch 'main' into get-orders-by-uid
m-sz Feb 25, 2026
a20e537
x-json to application/json
m-sz Feb 25, 2026
df906c5
Mention 128 MAX_ORDER_UID limit in cancellations API spec
m-sz Feb 26, 2026
917dfe6
Merge branch 'main' into get-orders-by-uid
m-sz Feb 26, 2026
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
10 changes: 10 additions & 0 deletions crates/database/src/jit_orders.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,16 @@ SELECT,
sqlx::query_as(QUERY).bind(uid).fetch_optional(ex).await
}

#[instrument(skip_all)]
pub async fn get_many_by_uid<'a>(
ex: &'a mut PgConnection,
order_uids: &'a [OrderUid],
) -> Result<Vec<orders::FullOrder>, sqlx::Error> {
const QUERY: &str =
const_format::concatcp!("SELECT ", SELECT, " FROM ", FROM, " WHERE o.uid = ANY($1)");
sqlx::query_as(QUERY).bind(order_uids).fetch_all(ex).await
}

#[instrument(skip_all)]
pub async fn get_by_tx(
ex: &mut PgConnection,
Expand Down
40 changes: 27 additions & 13 deletions crates/database/src/orders.rs
Original file line number Diff line number Diff line change
Expand Up @@ -644,6 +644,21 @@ COALESCE((SELECT executed_fee_token FROM order_execution oe WHERE oe.order_uid =
"#;

pub const FROM: &str = "orders o";
const FULL_ORDER_WITH_QUOTE: &str = const_format::concatcp!(
"SELECT ",
SELECT,
", o_quotes.sell_amount as quote_sell_amount",
", o_quotes.buy_amount as quote_buy_amount",
", o_quotes.gas_amount as quote_gas_amount",
", o_quotes.gas_price as quote_gas_price",
", o_quotes.sell_token_price as quote_sell_token_price",
", o_quotes.verified as quote_verified",
", o_quotes.metadata as quote_metadata",
", o_quotes.solver as solver",
" FROM ",
FROM,
Comment thread
m-sz marked this conversation as resolved.
" LEFT JOIN order_quotes o_quotes ON o.uid = o_quotes.order_uid",
);

#[instrument(skip_all)]
pub async fn single_full_order_with_quote(
Expand All @@ -652,22 +667,21 @@ pub async fn single_full_order_with_quote(
) -> Result<Option<FullOrderWithQuote>, sqlx::Error> {
#[rustfmt::skip]
const QUERY: &str = const_format::concatcp!(
"SELECT ", SELECT,
", o_quotes.sell_amount as quote_sell_amount",
", o_quotes.buy_amount as quote_buy_amount",
", o_quotes.gas_amount as quote_gas_amount",
", o_quotes.gas_price as quote_gas_price",
", o_quotes.sell_token_price as quote_sell_token_price",
", o_quotes.verified as quote_verified",
", o_quotes.metadata as quote_metadata",
", o_quotes.solver as solver",
" FROM ", FROM,
" LEFT JOIN order_quotes o_quotes ON o.uid = o_quotes.order_uid",
" WHERE o.uid = $1",
);
FULL_ORDER_WITH_QUOTE,
" WHERE o.uid = $1"
);
sqlx::query_as(QUERY).bind(uid).fetch_optional(ex).await
}

#[instrument(skip_all)]
pub async fn many_full_orders_with_quotes<'a>(
ex: &'a mut PgConnection,
order_ids: &'a [OrderUid],
) -> Result<Vec<FullOrderWithQuote>, sqlx::Error> {
const QUERY: &str = const_format::concatcp!(FULL_ORDER_WITH_QUOTE, " WHERE o.uid = ANY($1)");
sqlx::query_as(QUERY).bind(order_ids).fetch_all(ex).await
}

// Partial query for getting the log indices of events of a single settlement.
//
// This will fail if we ever have multiple settlements in the same transaction
Expand Down
27 changes: 27 additions & 0 deletions crates/e2e/tests/e2e/malformed_requests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

use {
e2e::setup::{API_HOST, OnchainComponents, Services, run_test},
model::order::{ORDER_UID_LIMIT, OrderUid},
orderbook::api::Error,
reqwest::StatusCode,
serde_json::json,
Expand Down Expand Up @@ -306,4 +307,30 @@ async fn http_validation(web3: Web3) {
!error.description.is_empty(),
"Error response should have non-empty 'description' field"
);

// /api/v1/orders/by_uids requires Json body
let response = client
.post(format!("{API_HOST}/api/v1/orders/by_uids"))
.header("Content-Type", "application/json")
.body("Not json")
.send()
.await
.unwrap();

assert_eq!(response.status(), StatusCode::BAD_REQUEST);

// Querying for more than ORDER_UID_LIMIT orders should fail
let response = client
.post(format!("{API_HOST}/api/v1/orders/by_uids"))
.header("Content-Type", "application/json")
.json(&json!(
(0..ORDER_UID_LIMIT + 1)
.map(|_| OrderUid::default())
.collect::<Vec<_>>()
))
.send()
.await
.unwrap();

assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
4 changes: 3 additions & 1 deletion crates/model/src/order.rs
Original file line number Diff line number Diff line change
Expand Up @@ -731,7 +731,9 @@ pub struct OrderMetadata {
pub quote: Option<OrderQuote>,
}

pub const ORDER_UID_LIMIT: usize = 1024;
/// OrderUid is 56 bytes. When hex encoded as 0x prefixes Json string it is 116.
/// Chosen to be under the MAX_JSON_BODY_PAYLOAD size of 1024 * 16
pub const ORDER_UID_LIMIT: usize = 128;
Comment thread
m-sz marked this conversation as resolved.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This constant is also used here:

if cancellations.data.order_uids.len() > ORDER_UID_LIMIT {

1024 -> 128 is a breaking change.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is not truly a breaking change since the MAX_JSON_BODY_PAYLOAD was limiting the cancellation data order uids anyway to 139 (The amount of order uids that fit with the SignedOrderCancellations). I decided to reuse the same limiting constant for both requests and rounded it up to 128.

If the 1024 (original) limit needs to be effective, We will need to increase the MAX_JSON_BODY_PAYLOAD, or do away with any order count limiting and just use MAX_JSON_BODY_PAYLOAD.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I am following. From the main branch:

if cancellations.data.order_uids.len() > ORDER_UID_LIMIT {

pub order_uids: Vec<OrderUid>,

It is literally the maximum number of UIDs in the requests. It was 1024, and now it is 128. Did I miss anything?

Copy link
Copy Markdown
Contributor Author

@m-sz m-sz Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, there is a default body limit at the api_router layer:

    api_router
        .layer(DefaultBodyLimit::max(MAX_JSON_BODY_PAYLOAD as usize))

which limits the request body to 16kb which equals to about 139 orders in the case of order cancellation payload. Which ultimately was the breaking change limiting the effective amount of order uids to 139.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Either we can also discuss removing the ORDER_UID_LIMIT altogether in both instances since we have the body limit in place.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, thanks! I missed that. Does it mean that with this PR it will now be limited to 128 instead of 1024?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this PR it will be limited to 128 instead of 139 effectively. The previous limit of 1024 was unrealistically high considering the size of hex encoded OrderUid and the body limit of 16kb.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By the way, You can try it out for yourself as is currently:

This will fail, the body is larger than 16kb (despite the 1024 max order limit, we are sending 133)

curl -v -X DELETE \
    -d "`jq -n '{"orderUids": [range(133) | "0xa8061d917531484d528329e6d6b99cc8bb7020d3436cdd8f4def4c6a8e78203604501b9b1d52e67f6862d157e00d13419d2d6e95ffffffff"], "signature": "0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", "signingScheme": "eip712" }'`" \
https://api.cow.fi/mainnet/api/v1/orders

132 orders will not fail the size check:

curl -v -X DELETE \
    -d "`jq -n '{"orderUids": [range(132) | "0xa8061d917531484d528329e6d6b99cc8bb7020d3436cdd8f4def4c6a8e78203604501b9b1d52e67f6862d157e00d13419d2d6e95ffffffff"], "signature": "0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", "signingScheme": "eip712" }'`" \
https://api.cow.fi/mainnet/api/v1/orders

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean, the code you share will work with 128 limit instead of 1024, no?

    api_router
        .layer(DefaultBodyLimit::max(MAX_JSON_BODY_PAYLOAD as usize))

Before this PR, the MAX_JSON_BODY_PAYLOAD was 1024, which means this layer allowed a body with a size up to 1024. And right now this is 128.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAX_JSON_BODY_PAYLOAD was 16kb, and ORDER_UID_LIMIT was 1024 those are distinct. MAX_JSON_BODY_PAYLOAD effectively limits every request to every endpoint to be under 16kB, while ORDER_UID_LIMIT is a custom constant used today in cancellations, and with this PR in bulk order querying to limit amount of orders that can come in a request. The MAX_JSON_BODY_PAYLOAD or ORDER_UID_LIMIT both have a chance to fire at a specific request. If the request containing a large amount of orders exceeds the body payload limit, it will not even go into the handler.
Then in the handler the parsed orders are checked if they fit under the ORDER_UID_LIMIT.

As discussed on Slack, let's keep both limits in place, since the ORDER_UID_LIMIT tells the user exactly what is wrong and what is the amount of orders that can be submitted at once.


// uid as 56 bytes: 32 for orderDigest, 20 for ownerAddress and 4 for validTo
#[derive(Clone, Copy, Eq, Hash, PartialEq, PartialOrd, Ord)]
Expand Down
56 changes: 55 additions & 1 deletion crates/orderbook/openapi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,60 @@ paths:
description: One or more orders were not found and no orders were cancelled.
"422":
description: Unable to parse request body as valid JSON.
"/api/v1/orders/by_uids":
post:
operationId: getOrders
summary: Get existing orders from the list of UIDs.
description: |
Returns an array where each element is an object with either
an "order" key containing the full order, or an "error" key
containing the UID and a description of what went wrong.
requestBody:
description: The list of up to 128 order uids to fetch
required: true
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/UID"
responses:
"200":
description: |
The resulting full order data based on the request. Each element of the array is
an object of the following format:
- `{"order": <Order>}` for successfully fetched orders
- `{"error": {"uid": "<UID>", "description": "<message>"}}` for orders that failed conversion
The result ordering is not guaranteed and might differ from the order of requested UIDs.
Orders that do not exist in the database will be missing from the response.
Comment thread
m-sz marked this conversation as resolved.
content:
application/json:
schema:
oneOf:
- type: object
properties:
order:
$ref: "#/components/schemas/Order"
required:
- order
- type: object
properties:
error:
type: object
properties:
uid:
$ref: "#/components/schemas/UID"
description:
type: string
required:
- uid
- description
required:
- error
"400":
description: Request array size larger than 128 or unable to parse request body as valid JSON.
Comment thread
m-sz marked this conversation as resolved.
"422":
description: Could not decode array values as order UID.
"/api/v1/orders/{UID}":
get:
operationId: getOrder
Expand Down Expand Up @@ -1510,7 +1564,7 @@ components:
properties:
orderUids:
type: array
description: UIDs of orders to cancel.
description: Up to 128 UIDs of orders to cancel.
items:
$ref: "#/components/schemas/UID"
signature:
Expand Down
6 changes: 6 additions & 0 deletions crates/orderbook/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ mod get_native_price;
mod get_order_by_uid;
mod get_order_status;
mod get_orders_by_tx;
mod get_orders_by_uid;
mod get_solver_competition;
mod get_solver_competition_v2;
mod get_token_metadata;
Expand Down Expand Up @@ -190,6 +191,11 @@ pub fn handle_all_routes(
"/api/v1/orders",
post(post_order::post_order_handler),
),
(
"POST",
"/api/v1/orders/by_uids",
post(get_orders_by_uid::get_orders_by_uid_handler),
Comment thread
m-sz marked this conversation as resolved.
),
(
"DELETE",
"/api/v1/orders",
Expand Down
122 changes: 122 additions & 0 deletions crates/orderbook/src/api/get_orders_by_uid.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
use {
crate::api::AppState,
anyhow::Result,
axum::{
extract::State,
http::StatusCode,
response::{IntoResponse, Response},
},
model::order::{ORDER_UID_LIMIT, Order, OrderUid},
serde::Serialize,
std::sync::Arc,
};

#[expect(clippy::large_enum_variant)]
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
enum OrderResultEntry {
Order(Order),
Error(OrderError),
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct OrderError {
uid: OrderUid,
description: String,
}

pub async fn get_orders_by_uid_handler(
State(state): State<Arc<AppState>>,
axum::Json(orders): axum::Json<Vec<OrderUid>>,
Comment thread
m-sz marked this conversation as resolved.
) -> Response {
if orders.len() > ORDER_UID_LIMIT {
return (
StatusCode::BAD_REQUEST,
format!("Request exceeds maximum number of order UIDs of {ORDER_UID_LIMIT}"),
)
.into_response();
}

get_orders_by_uid_response(state.orderbook.get_orders(&orders).await)
}

fn get_orders_by_uid_response(result: Result<Vec<(OrderUid, Result<Order>)>>) -> Response {
match result {
Ok(orders) => axum::Json(
orders
.into_iter()
.map(|(uid, order)| match order {
Ok(order) => OrderResultEntry::Order(order),
Err(err) => {
tracing::warn!(?err, "Error converting into model order");
Comment thread
m-sz marked this conversation as resolved.
OrderResultEntry::Error(OrderError {
uid,
description: "Internal server error encountered when retrieving the \
order"
.to_string(),
})
}
})
.collect::<Vec<OrderResultEntry>>(),
)
.into_response(),
Err(err) => {
tracing::error!(?err, "get_orders_by_uid_response");
crate::api::internal_error_reply()
}
}
}

#[cfg(test)]
mod tests {
use {super::*, crate::api::response_body};

#[tokio::test]
async fn get_orders_by_uid_ok() {
let order = Order::default();
let uid = order.metadata.uid;
let result = vec![(uid, Ok(order.clone()))];
let response = get_orders_by_uid_response(Ok(result));
assert_eq!(response.status(), StatusCode::OK);

let body = response_body(response).await;
let entries: Vec<serde_json::Value> = serde_json::from_slice(&body).unwrap();
assert_eq!(entries.len(), 1);

let order_entry: Order =
serde_json::from_value(entries[0].get("order").expect("key order exists").clone())
.expect("value is a correct Order");
assert_eq!(order_entry, order);
}

#[tokio::test]
async fn get_orders_by_uid_conversion_error() {
let uid = OrderUid([1u8; 56]);
let result = vec![(uid, Err(anyhow::anyhow!("bad data")))];
let response = get_orders_by_uid_response(Ok(result));
assert_eq!(response.status(), StatusCode::OK);

let body = response_body(response).await;
let entries: Vec<serde_json::Value> = serde_json::from_slice(&body).unwrap();
assert_eq!(entries.len(), 1);

let error = entries[0].get("error").expect("error key exists");
let error_uid: OrderUid = error.get("uid").unwrap().as_str().unwrap().parse().unwrap();
assert_eq!(error_uid, uid);
assert_eq!(
error
.get("description")
.expect("key description exists")
.as_str()
.expect("description is a string"),
"Internal server error encountered when retrieving the order"
);
}

#[tokio::test]
async fn get_orders_by_uid_err() {
let response = get_orders_by_uid_response(Err(anyhow::anyhow!("error")));
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
Comment thread
m-sz marked this conversation as resolved.
}
}
Loading
Loading