diff --git a/crates/solana-orderbook/openapi.yml b/crates/solana-orderbook/openapi.yml index e08903d8ad..74918944bf 100644 --- a/crates/solana-orderbook/openapi.yml +++ b/crates/solana-orderbook/openapi.yml @@ -9,6 +9,49 @@ servers: - description: Solana (Staging) url: "https://barn.api.cow.fi/solana" paths: + /api/v1/account/{owner}/orders: + get: + operationId: getUserOrdersPaginated + description: | + The owner's orders, sorted by creation date descending (newest first). + To enumerate all orders start with `offset` 0 and keep increasing it + by the number of returned results. A response shorter than `limit` is + the last page. + parameters: + - name: owner + in: path + required: true + schema: + $ref: "#/components/schemas/Pubkey" + - name: offset + in: query + description: The pagination offset. Defaults to 0. + schema: + type: integer + required: false + - name: limit + in: query + description: The pagination limit. Defaults to 10. Maximum 1000. Minimum 1. + schema: + type: integer + required: false + responses: + "200": + description: The orders. + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Order" + "400": + description: | + The owner is not a valid public key (`InvalidOwner`) or the limit + is out of bounds (`LIMIT_OUT_OF_BOUNDS`). + content: + application/json: + schema: + $ref: "#/components/schemas/Error" /api/v1/orders/{uid}: get: operationId: getOrder diff --git a/crates/solana-orderbook/src/infra/api/error.rs b/crates/solana-orderbook/src/infra/api/error.rs index 0bb43a90e1..e97e2260ce 100644 --- a/crates/solana-orderbook/src/infra/api/error.rs +++ b/crates/solana-orderbook/src/infra/api/error.rs @@ -10,19 +10,23 @@ use { #[serde(rename_all = "camelCase")] pub struct Error { pub error_type: &'static str, - pub description: &'static str, + pub description: String, } /// An error response: the status code and the error body. pub type Reply = (StatusCode, Json); /// Build an error response. -pub fn reply(status: StatusCode, error_type: &'static str, description: &'static str) -> Reply { +pub fn reply( + status: StatusCode, + error_type: &'static str, + description: impl Into, +) -> Reply { ( status, Json(Error { error_type, - description, + description: description.into(), }), ) } diff --git a/crates/solana-orderbook/src/infra/api/mod.rs b/crates/solana-orderbook/src/infra/api/mod.rs index 830a4a36f6..63b5567a85 100644 --- a/crates/solana-orderbook/src/infra/api/mod.rs +++ b/crates/solana-orderbook/src/infra/api/mod.rs @@ -99,6 +99,10 @@ impl Api { let app = Router::new() .route("/healthz", get(routes::healthz)) + .route( + "/api/v1/account/{owner}/orders", + get(routes::account_orders), + ) .route("/api/v1/orders/{uid}", get(routes::order)) .route("/api/v1/orders/{uid}/status", get(routes::order_status)) .route("/api/v2/trades", get(routes::trades)) diff --git a/crates/solana-orderbook/src/infra/api/routes/account/mod.rs b/crates/solana-orderbook/src/infra/api/routes/account/mod.rs new file mode 100644 index 0000000000..579be3dd30 --- /dev/null +++ b/crates/solana-orderbook/src/infra/api/routes/account/mod.rs @@ -0,0 +1,72 @@ +//! The account orders endpoint: one owner's orders, paginated. + +use { + super::order::{dto, now_unix}, + crate::infra::{ + api::{State, error}, + db, + }, + axum::{ + Json, + extract::{Path, Query}, + http::StatusCode, + }, + serde::Deserialize, + solana_sdk::pubkey::Pubkey, + std::str::FromStr, +}; + +const DEFAULT_OFFSET: u64 = 0; +const DEFAULT_LIMIT: u64 = 10; +const MIN_LIMIT: u64 = 1; +const MAX_LIMIT: u64 = 1000; + +/// Pagination parameters, with the EVM orderbook's defaults and bounds. The +/// unsigned types reject negative values at deserialization, as on EVM. +#[derive(Debug, Deserialize)] +pub struct Params { + pub offset: Option, + pub limit: Option, +} + +/// Handle `GET /api/v1/account/{owner}/orders`: the owner's orders with +/// their fill state, newest first. +pub async fn account_orders( + state: axum::extract::State, + Path(owner): Path, + Query(params): Query, +) -> Result>, error::Reply> { + let owner = Pubkey::from_str(&owner).map_err(|_| { + error::reply( + StatusCode::BAD_REQUEST, + "InvalidOwner", + "owner must be a base58-encoded public key", + ) + })?; + let offset = params.offset.unwrap_or(DEFAULT_OFFSET); + let limit = params.limit.unwrap_or(DEFAULT_LIMIT); + if !(MIN_LIMIT..=MAX_LIMIT).contains(&limit) { + return Err(error::reply( + StatusCode::BAD_REQUEST, + "LIMIT_OUT_OF_BOUNDS", + format!("The pagination limit is [{MIN_LIMIT},{MAX_LIMIT}]."), + )); + } + // The limit is bounded above, and an offset past i64::MAX addresses no + // conceivable row. + let offset = i64::try_from(offset).unwrap_or(i64::MAX); + let limit = i64::try_from(limit).expect("limit is at most 1000"); + + let rows = db::orders_by_owner(state.pool(), owner.to_bytes(), offset, limit) + .await + .map_err(|err| { + tracing::error!(?err, "account orders lookup failed"); + error::reply(StatusCode::INTERNAL_SERVER_ERROR, "InternalServerError", "") + })?; + let now = now_unix(); + Ok(Json( + rows.into_iter() + .map(|row| dto::Order::new(row, now)) + .collect(), + )) +} diff --git a/crates/solana-orderbook/src/infra/api/routes/mod.rs b/crates/solana-orderbook/src/infra/api/routes/mod.rs index c96ede80d8..35c0c4a80d 100644 --- a/crates/solana-orderbook/src/infra/api/routes/mod.rs +++ b/crates/solana-orderbook/src/infra/api/routes/mod.rs @@ -1,7 +1,15 @@ +mod account; mod healthz; mod order; mod quote; mod status; mod trades; -pub use {healthz::healthz, order::order, quote::quote, status::order_status, trades::trades}; +pub use { + account::account_orders, + healthz::healthz, + order::order, + quote::quote, + status::order_status, + trades::trades, +}; diff --git a/crates/solana-orderbook/src/infra/api/routes/order/mod.rs b/crates/solana-orderbook/src/infra/api/routes/order/mod.rs index 7fb939056b..bcc940a479 100644 --- a/crates/solana-orderbook/src/infra/api/routes/order/mod.rs +++ b/crates/solana-orderbook/src/infra/api/routes/order/mod.rs @@ -26,7 +26,7 @@ pub async fn order( Ok(Json(dto::Order::new(row, now_unix()))) } -fn now_unix() -> i64 { +pub(super) fn now_unix() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) .expect("system clock after the unix epoch") diff --git a/crates/solana-orderbook/src/infra/api/routes/trades/mod.rs b/crates/solana-orderbook/src/infra/api/routes/trades/mod.rs index da36175abc..17b4ecf2cb 100644 --- a/crates/solana-orderbook/src/infra/api/routes/trades/mod.rs +++ b/crates/solana-orderbook/src/infra/api/routes/trades/mod.rs @@ -75,7 +75,7 @@ pub async fn trades( return Err(error::reply( StatusCode::BAD_REQUEST, "InvalidLimit", - "limit must be between 1 and 1000", + format!("limit must be between {MIN_LIMIT} and {MAX_LIMIT}"), )); } // The limit is bounded above, and an offset past i64::MAX addresses no diff --git a/crates/solana-orderbook/src/infra/db.rs b/crates/solana-orderbook/src/infra/db.rs index 96717ed9e1..a79bb3f5c7 100644 --- a/crates/solana-orderbook/src/infra/db.rs +++ b/crates/solana-orderbook/src/infra/db.rs @@ -54,6 +54,36 @@ WHERE o.uid = $1 .context("read solana.orders by uid") } +/// A page of one owner's orders with their fill state, newest first. +pub async fn orders_by_owner( + ex: impl PgExecutor<'_>, + owner: [u8; 32], + offset: i64, + limit: i64, +) -> Result> { + const QUERY: &str = r#" +SELECT o.uid, o.owner, o.sell_token, o.buy_token, o.sell_token_account, + o.buy_token_account, o.sell_amount, o.buy_amount, o.valid_to, + o.kind, o.partially_fillable, o.app_data, + o.creation_timestamp, o.order_pda, + COALESCE(p.amount_withdrawn, 0) AS amount_withdrawn, + COALESCE(p.amount_received, 0) AS amount_received, + p.cancellation_timestamp +FROM solana.orders o +LEFT JOIN solana.order_pda p ON p.order_uid = o.uid +WHERE o.owner = $1 +ORDER BY o.creation_timestamp DESC +LIMIT $2 OFFSET $3 + "#; + sqlx::query_as(QUERY) + .bind(ByteArray(owner)) + .bind(limit) + .bind(offset) + .fetch_all(ex) + .await + .context("read solana.orders by owner") +} + /// One trade joined with its order's identity and the settlement's slot. #[derive(Clone, Debug, sqlx::FromRow)] pub struct TradeRow { @@ -177,6 +207,47 @@ VALUES ($1, $2, 400, CASE WHEN $3 THEN now() END) .unwrap(); } + /// Pagination walks one owner's orders newest first, other owners are + /// excluded, and the fill state joins in. + #[tokio::test] + #[ignore = "needs the solana.* schema applied to the local database"] + async fn solana_db_reads_orders_by_owner_paginated() { + let pool = PgPool::connect("postgresql://").await.unwrap(); + seed(&pool, [0x11; 32], false).await; + // A second, older order of the same owner, and one of another owner. + for (uid, owner, age) in [ + ([0x12u8; 32], [0xAAu8; 32], "1 hour"), + ([0x13; 32], [0xCC; 32], "2 hours"), + ] { + sqlx::query( + r#" +INSERT INTO solana.orders (uid, owner, sell_token, buy_token, sell_token_account, + buy_token_account, sell_amount, buy_amount, valid_to, kind, + partially_fillable, app_data, creation_timestamp, order_pda) +VALUES ($1, $2, $2, $2, $2, $2, 1000, 500, $3, 'sell'::solana.OrderKind, + false, $2, now() - $4::interval, $1) + "#, + ) + .bind(ByteArray(uid)) + .bind(ByteArray(owner)) + .bind(i64::from(u32::MAX)) + .bind(age) + .execute(&pool) + .await + .unwrap(); + } + + let page = orders_by_owner(&pool, [0xAA; 32], 0, 10).await.unwrap(); + let uids: Vec<_> = page.iter().map(|row| row.uid).collect(); + assert_eq!(uids, vec![ByteArray([0x11; 32]), ByteArray([0x12; 32])]); + assert_eq!(page[0].amount_withdrawn, BigDecimal::from(400)); + assert_eq!(page[1].amount_withdrawn, BigDecimal::from(0)); + + let second = orders_by_owner(&pool, [0xAA; 32], 1, 1).await.unwrap(); + assert_eq!(second.len(), 1); + assert_eq!(second[0].uid, ByteArray([0x12; 32])); + } + #[tokio::test] #[ignore = "needs the solana.* schema applied to the local database"] async fn solana_db_reads_an_order_with_fill_state() { diff --git a/crates/solana-orderbook/tests/api.rs b/crates/solana-orderbook/tests/api.rs index 9e34924644..28e8816698 100644 --- a/crates/solana-orderbook/tests/api.rs +++ b/crates/solana-orderbook/tests/api.rs @@ -303,3 +303,31 @@ async fn trades_rejects_an_invalid_limit() { assert_eq!(json["errorType"], "InvalidLimit"); } } + +/// Parameter validation of the account orders endpoint short-circuits before +/// any database access. +#[tokio::test] +async fn account_orders_rejects_bad_parameters() { + let addr = spawn_server().await; + let client = reqwest::Client::new(); + + let response = client + .get(format!("http://{addr}/api/v1/account/not-a-pubkey/orders")) + .send() + .await + .unwrap(); + assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST); + let json: serde_json::Value = response.json().await.unwrap(); + assert_eq!(json["errorType"], "InvalidOwner"); + + let response = client + .get(format!( + "http://{addr}/api/v1/account/9VXC6LH9eXMBpXLQnxMYAGkjs59Zon2ACciJwQ6iMzNB/orders?limit=0" + )) + .send() + .await + .unwrap(); + assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST); + let json: serde_json::Value = response.json().await.unwrap(); + assert_eq!(json["errorType"], "LIMIT_OUT_OF_BOUNDS"); +}