Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
43 changes: 43 additions & 0 deletions crates/solana-orderbook/openapi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions crates/solana-orderbook/src/infra/api/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Error>);

/// 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<String>,
) -> Reply {
(
status,
Json(Error {
error_type,
description,
description: description.into(),
}),
)
}
Expand Down
4 changes: 4 additions & 0 deletions crates/solana-orderbook/src/infra/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
72 changes: 72 additions & 0 deletions crates/solana-orderbook/src/infra/api/routes/account/mod.rs
Original file line number Diff line number Diff line change
@@ -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<u64>,
pub limit: Option<u64>,
}

/// 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<State>,
Path(owner): Path<String>,
Query(params): Query<Params>,
) -> Result<Json<Vec<dto::Order>>, 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(),
))
}
10 changes: 9 additions & 1 deletion crates/solana-orderbook/src/infra/api/routes/mod.rs
Original file line number Diff line number Diff line change
@@ -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,
};
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
71 changes: 71 additions & 0 deletions crates/solana-orderbook/src/infra/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<OrderRow>> {
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 {
Expand Down Expand Up @@ -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() {
Expand Down
28 changes: 28 additions & 0 deletions crates/solana-orderbook/tests/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Loading