use alloy_primitives::Address;
use alloy_signer::SignerSync;
use crate::{
AppDataDoc, Chain, Error, OrderBookApi, OrderCreation, OrderData, OrderQuoteResponse, OrderUid,
QuoteRequest, Result, signing_scheme::EcdsaSigningScheme,
};
#[derive(Debug, Clone)]
pub struct SwapOrder<'a> {
pub request: QuoteRequest,
pub app_data: &'a AppDataDoc,
pub scheme: EcdsaSigningScheme,
pub partner_fee_bps: u32,
pub slippage_bps: u32,
pub protocol_fee_bps_override: Option<String>,
}
impl<'a> SwapOrder<'a> {
pub const fn eip712(request: QuoteRequest, app_data: &'a AppDataDoc) -> Self {
Self {
request,
app_data,
scheme: EcdsaSigningScheme::Eip712,
partner_fee_bps: 0,
slippage_bps: 50,
protocol_fee_bps_override: None,
}
}
pub const fn with_partner_fee_bps(mut self, bps: u32) -> Self {
self.partner_fee_bps = bps;
self
}
pub const fn with_slippage_bps(mut self, bps: u32) -> Self {
self.slippage_bps = bps;
self
}
pub const fn with_ethsign(mut self) -> Self {
self.scheme = EcdsaSigningScheme::EthSign;
self
}
}
#[derive(Debug, Clone)]
pub struct PostedSwapOrder {
pub uid: OrderUid,
pub order_data: OrderData,
pub quote: OrderQuoteResponse,
}
#[derive(Debug, Clone)]
pub struct TradingClient {
api: OrderBookApi,
chain: Chain,
}
impl TradingClient {
pub fn new(chain: Chain) -> Self {
Self {
api: OrderBookApi::new(chain),
chain,
}
}
pub const fn from_orderbook(chain: Chain, api: OrderBookApi) -> Self {
Self { api, chain }
}
pub const fn chain(&self) -> Chain {
self.chain
}
pub const fn orderbook(&self) -> &OrderBookApi {
&self.api
}
pub async fn post_swap_order<S>(
&self,
params: SwapOrder<'_>,
signer: &S,
) -> Result<PostedSwapOrder>
where
S: SignerSync,
{
let app_data_hash = params.app_data.hash();
let app_data_json = params.app_data.canonical_json();
let quote = self.api.quote(¶ms.request).await?;
let order_data = quote.try_into_signed_order_data_with_costs(
¶ms.request,
params.partner_fee_bps,
params.slippage_bps,
params.protocol_fee_bps_override.as_deref(),
app_data_hash,
)?;
let domain = crate::domain::settlement_domain(self.chain.id(), self.chain.settlement());
let signature = order_data
.sign(params.scheme, &domain, signer)
.map_err(Error::Signature)?;
if params.request.from == Address::ZERO {
return Err(Error::OrderCreationInvalid {
field: "from",
reason: "QuoteRequest.from must be the order owner; \
TradingClient does not infer it from the signer",
});
}
let from = params.request.from;
let body = OrderCreation::from_signed_order_data(
&order_data,
signature,
from,
app_data_json.clone(),
Some(quote.id),
)?;
body.verify_owner(&domain).map_err(Error::Signature)?;
self.api
.put_app_data(
&app_data_hash,
&crate::AppDataDocument {
full_app_data: app_data_json,
},
)
.await?;
let uid = self.api.post_order(&body).await?;
Ok(PostedSwapOrder {
uid,
order_data,
quote,
})
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use super::*;
use crate::{AppDataDoc, OrderBookApi, SwapOrder};
use alloy_primitives::{U256, address};
use alloy_signer_local::PrivateKeySigner;
use serde_json::{Value, json};
use std::sync::{Arc, Mutex};
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{method, path},
};
const USDC: Address = address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48");
const DAI: Address = address!("6B175474E89094C44Da98b954EedeAC495271d0F");
fn quote_body(from: Address) -> Value {
json!({
"quote": {
"sellToken": format!("{:#x}", USDC),
"buyToken": format!("{:#x}", DAI),
"receiver": null,
"sellAmount": "1000000000000000000",
"buyAmount": "2000000000000000000",
"validTo": 1_900_000_000_u32,
"appData": "0x0000000000000000000000000000000000000000000000000000000000000000",
"feeAmount": "0",
"kind": "sell",
"partiallyFillable": false,
"sellTokenBalance": "erc20",
"buyTokenBalance": "erc20",
"signingScheme": "eip712",
},
"from": format!("{from:#x}"),
"expiration": "2099-12-31T23:59:59Z",
"id": 42,
"verified": true,
})
}
#[tokio::test]
async fn post_swap_order_rejects_signer_mismatch_before_posting() {
let signer = PrivateKeySigner::random();
let signer_addr = signer.address();
let declared_from = address!("dead0000dead0000dead0000dead0000dead0000");
assert_ne!(signer_addr, declared_from);
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/quote"))
.respond_with(ResponseTemplate::new(200).set_body_json(quote_body(declared_from)))
.mount(&server)
.await;
let post_calls = Arc::new(Mutex::new(Vec::<Value>::new()));
let post_calls_handle = post_calls.clone();
Mock::given(method("POST"))
.and(path("/api/v1/orders"))
.respond_with(move |req: &wiremock::Request| {
let body: Value =
serde_json::from_slice(&req.body).expect("orderbook body is JSON");
post_calls_handle.lock().unwrap().push(body);
ResponseTemplate::new(201).set_body_json(Value::String("0x".repeat(56)))
})
.mount(&server)
.await;
let api = OrderBookApi::new_with_base_url(server.uri().parse().unwrap());
let client = TradingClient::from_orderbook(Chain::Mainnet, api);
let app_data = AppDataDoc::sdk_attribution("cow-rs");
let request = QuoteRequest::sell_before_fee(
USDC,
DAI,
declared_from,
U256::from(1_000_000_000_000_000_000_u64),
);
let params = SwapOrder::eip712(request, &app_data);
let err = client
.post_swap_order(params, &signer)
.await
.expect_err("client must reject signer/from mismatch before posting");
assert!(
matches!(
err,
Error::Signature(crate::signature::SignatureError::SignerMismatch { .. })
),
"expected SignerMismatch, got: {err:?}",
);
assert!(
post_calls.lock().unwrap().is_empty(),
"POST /api/v1/orders must not be reached when verify_owner fails",
);
}
}