1use std::collections::{HashMap, HashSet};
19
20use ahash::{AHashMap, AHashSet};
21use alloy_primitives::Address;
22use derive_builder::Builder;
23use jiff::{Timestamp, civil::Date, tz::Offset};
24use rust_decimal::Decimal;
25use serde::{
26 Deserialize, Deserializer, Serialize,
27 de::{Error, IgnoredAny, MapAccess, Visitor},
28};
29
30use crate::{
31 common::{
32 enums::{PolymarketOrderType, SignatureType},
33 parse::{deserialize_decimal_from_str, deserialize_optional_decimal_from_str},
34 },
35 http::models::PolymarketOrder,
36};
37
38#[derive(Clone, Debug, Default, Serialize, Builder)]
40#[builder(setter(into, strip_option), default)]
41pub struct GetOrdersParams {
42 #[serde(skip_serializing_if = "Option::is_none")]
43 pub id: Option<String>,
44 #[serde(skip_serializing_if = "Option::is_none")]
45 pub market: Option<String>,
46 #[serde(skip_serializing_if = "Option::is_none")]
47 pub asset_id: Option<String>,
48 #[serde(skip_serializing_if = "Option::is_none")]
49 pub next_cursor: Option<String>,
50}
51
52#[derive(Clone, Debug, Default, Serialize, Builder)]
54#[builder(setter(into, strip_option), default)]
55pub struct GetTradesParams {
56 #[serde(skip_serializing_if = "Option::is_none")]
57 pub id: Option<String>,
58 #[serde(skip_serializing_if = "Option::is_none")]
59 pub maker_address: Option<String>,
60 #[serde(skip_serializing_if = "Option::is_none")]
61 pub market: Option<String>,
62 #[serde(skip_serializing_if = "Option::is_none")]
63 pub asset_id: Option<String>,
64 #[serde(skip_serializing_if = "Option::is_none")]
65 pub before: Option<u64>,
66 #[serde(skip_serializing_if = "Option::is_none")]
67 pub after: Option<u64>,
68 #[serde(skip_serializing_if = "Option::is_none")]
69 pub next_cursor: Option<String>,
70}
71
72#[derive(Clone, Debug, Default, Serialize, Builder)]
74#[builder(setter(into, strip_option), default)]
75pub struct GetBalanceAllowanceParams {
76 #[serde(skip_serializing_if = "Option::is_none")]
77 pub asset_type: Option<AssetType>,
78 #[serde(skip_serializing_if = "Option::is_none")]
79 pub token_id: Option<String>,
80 #[serde(skip_serializing_if = "Option::is_none")]
81 pub signature_type: Option<SignatureType>,
82}
83
84#[derive(Clone, Debug, Default, Serialize, Builder)]
86#[builder(setter(into, strip_option), default)]
87pub struct CancelMarketOrdersParams {
88 #[serde(skip_serializing_if = "Option::is_none")]
89 pub market: Option<String>,
90 #[serde(skip_serializing_if = "Option::is_none")]
91 pub asset_id: Option<String>,
92}
93
94#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
97pub enum AssetType {
98 Collateral,
99 Conditional,
100}
101
102#[derive(Clone, Debug, Deserialize)]
109pub struct BalanceAllowance {
110 #[serde(deserialize_with = "deserialize_decimal_from_str")]
111 pub balance: Decimal,
112 #[serde(default, deserialize_with = "deserialize_rejected_legacy_allowance")]
116 pub allowance: Option<Decimal>,
117 #[serde(deserialize_with = "deserialize_spender_allowances")]
118 pub allowances: HashMap<String, String>,
119}
120
121fn deserialize_rejected_legacy_allowance<'de, D>(
122 deserializer: D,
123) -> Result<Option<Decimal>, D::Error>
124where
125 D: Deserializer<'de>,
126{
127 match Option::<IgnoredAny>::deserialize(deserializer)? {
128 None => Ok(None),
129 Some(_) => Err(D::Error::custom(
130 "legacy singular `allowance` is not accepted; use plural `allowances` evidence",
131 )),
132 }
133}
134
135struct CanonicalSpenderKey {
136 raw: String,
137 address: Address,
138}
139
140impl<'de> Deserialize<'de> for CanonicalSpenderKey {
141 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
142 where
143 D: Deserializer<'de>,
144 {
145 let raw = String::deserialize(deserializer)?;
146 let invalid_spender = format!("invalid spender `{raw}` in allowance evidence");
147 let address = raw
148 .parse::<Address>()
149 .map_err(|_| D::Error::custom(&invalid_spender))?;
150 let is_canonical = [format!("{address:#x}"), address.to_checksum(None)]
151 .into_iter()
152 .any(|candidate| candidate == raw);
153
154 is_canonical
155 .then_some(Self { raw, address })
156 .ok_or_else(|| D::Error::custom(invalid_spender))
157 }
158}
159
160fn deserialize_spender_allowances<'de, D>(
161 deserializer: D,
162) -> Result<HashMap<String, String>, D::Error>
163where
164 D: Deserializer<'de>,
165{
166 struct SpenderAllowancesVisitor;
167
168 impl<'de> Visitor<'de> for SpenderAllowancesVisitor {
169 type Value = HashMap<String, String>;
170
171 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
172 formatter.write_str("a spender-to-allowance map without duplicate spenders")
173 }
174
175 fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
176 where
177 A: MapAccess<'de>,
178 {
179 let mut allowances = HashMap::new();
180 let mut seen_spenders = HashSet::new();
181 while let Some(spender) = map.next_key::<CanonicalSpenderKey>()? {
182 if !seen_spenders.insert(spender.address) {
183 return Err(A::Error::custom(format!(
184 "duplicate spender `{}` in allowance evidence",
185 spender.raw,
186 )));
187 }
188 let allowance = map.next_value::<String>()?;
189 allowances.insert(spender.raw, allowance);
190 }
191 Ok(allowances)
192 }
193 }
194
195 deserializer.deserialize_map(SpenderAllowancesVisitor)
196}
197
198#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
200pub struct ClobVersionResponse {
201 pub version: u8,
202}
203
204#[derive(Clone, Copy, Debug, PartialEq, Eq)]
206pub enum OrderResponseStatus {
207 Live,
208 Matched,
209 Delayed,
210 Unmatched,
211}
212
213#[derive(Clone, Debug, Deserialize)]
215pub struct OrderResponse {
216 pub success: bool,
217 #[serde(rename = "orderID")]
218 pub order_id: Option<String>,
219 #[serde(
220 default,
221 deserialize_with = "deserialize_optional_order_response_status"
222 )]
223 pub status: Option<OrderResponseStatus>,
224 #[serde(
225 default,
226 rename = "makingAmount",
227 deserialize_with = "deserialize_optional_decimal_from_str"
228 )]
229 pub making_amount: Option<Decimal>,
230 #[serde(
231 default,
232 rename = "takingAmount",
233 deserialize_with = "deserialize_optional_decimal_from_str"
234 )]
235 pub taking_amount: Option<Decimal>,
236 #[serde(rename = "transactionsHashes")]
237 pub transaction_hashes: Option<Vec<String>>,
238 #[serde(rename = "tradeIDs")]
239 pub trade_ids: Option<Vec<String>>,
240 #[serde(rename = "errorMsg")]
241 pub error_msg: Option<String>,
242}
243
244fn deserialize_optional_order_response_status<'de, D>(
245 deserializer: D,
246) -> Result<Option<OrderResponseStatus>, D::Error>
247where
248 D: Deserializer<'de>,
249{
250 match Option::<String>::deserialize(deserializer)?.as_deref() {
251 None | Some("") => Ok(None),
252 Some("live") => Ok(Some(OrderResponseStatus::Live)),
253 Some("matched") => Ok(Some(OrderResponseStatus::Matched)),
254 Some("delayed") => Ok(Some(OrderResponseStatus::Delayed)),
255 Some("unmatched") => Ok(Some(OrderResponseStatus::Unmatched)),
256 Some(value) => Err(D::Error::unknown_variant(
257 value,
258 &["live", "matched", "delayed", "unmatched"],
259 )),
260 }
261}
262
263#[derive(Clone, Debug, Default, Deserialize)]
269pub struct CancelResponse {
270 #[serde(default)]
271 pub canceled: Vec<String>,
272 #[serde(default)]
273 pub not_canceled: AHashMap<String, Option<String>>,
274}
275
276impl CancelResponse {
277 pub(crate) fn merge(&mut self, mut response: Self) {
278 self.canceled.append(&mut response.canceled);
279 self.not_canceled.extend(response.not_canceled);
280 }
281}
282
283pub type BatchCancelResponse = CancelResponse;
285
286#[derive(Clone, Debug, Serialize)]
288#[serde(rename_all = "camelCase")]
289pub struct PostOrderParams {
290 pub order_type: PolymarketOrderType,
291 #[serde(skip_serializing_if = "std::ops::Not::not")]
292 pub post_only: bool,
293}
294
295#[derive(Clone, Debug, Serialize)]
297#[serde(rename_all = "camelCase")]
298pub struct OrderSubmission {
299 pub order: PolymarketOrder,
300 pub order_type: PolymarketOrderType,
301 #[serde(skip_serializing_if = "std::ops::Not::not")]
302 pub post_only: bool,
303}
304
305#[derive(Clone, Debug, Default, Serialize, Builder)]
307#[builder(setter(into, strip_option), default)]
308pub struct GetGammaMarketsParams {
309 #[serde(skip_serializing_if = "Option::is_none")]
311 pub active: Option<bool>,
312 #[serde(skip_serializing_if = "Option::is_none")]
313 pub closed: Option<bool>,
314 #[serde(skip_serializing_if = "Option::is_none")]
316 pub archived: Option<bool>,
317 #[serde(skip_serializing_if = "Option::is_none")]
318 pub id: Option<Vec<u64>>,
319 #[serde(skip_serializing_if = "Option::is_none")]
320 pub limit: Option<u32>,
321 #[serde(skip_serializing_if = "Option::is_none")]
323 pub offset: Option<u32>,
324 #[serde(skip_serializing_if = "Option::is_none")]
325 pub order: Option<String>,
326 #[serde(skip_serializing_if = "Option::is_none")]
327 pub ascending: Option<bool>,
328 #[serde(skip_serializing_if = "Option::is_none")]
329 pub slug: Option<Vec<String>>,
330 #[serde(skip_serializing_if = "Option::is_none")]
331 pub clob_token_ids: Option<Vec<String>>,
332 #[serde(skip_serializing_if = "Option::is_none")]
333 pub condition_ids: Option<Vec<String>>,
334 #[serde(skip_serializing_if = "Option::is_none")]
335 pub question_ids: Option<Vec<String>>,
336 #[serde(skip_serializing_if = "Option::is_none")]
337 pub market_maker_address: Option<Vec<String>>,
338 #[serde(skip_serializing_if = "Option::is_none")]
339 pub liquidity_num_min: Option<Decimal>,
340 #[serde(skip_serializing_if = "Option::is_none")]
341 pub liquidity_num_max: Option<Decimal>,
342 #[serde(skip_serializing_if = "Option::is_none")]
343 pub volume_num_min: Option<Decimal>,
344 #[serde(skip_serializing_if = "Option::is_none")]
345 pub volume_num_max: Option<Decimal>,
346 #[serde(skip_serializing_if = "Option::is_none")]
348 pub start_date_min: Option<String>,
349 #[serde(skip_serializing_if = "Option::is_none")]
351 pub start_date_max: Option<String>,
352 #[serde(skip_serializing_if = "Option::is_none")]
354 pub end_date_min: Option<String>,
355 #[serde(skip_serializing_if = "Option::is_none")]
357 pub end_date_max: Option<String>,
358 #[serde(skip_serializing_if = "Option::is_none")]
359 pub tag_id: Option<Vec<u64>>,
360 #[serde(skip_serializing_if = "Option::is_none")]
361 pub related_tags: Option<bool>,
362 #[serde(skip_serializing_if = "Option::is_none")]
363 pub tag_match: Option<String>,
364 #[serde(skip_serializing_if = "Option::is_none")]
365 pub decimalized: Option<bool>,
366 #[serde(skip_serializing_if = "Option::is_none")]
367 pub cyom: Option<bool>,
368 #[serde(skip_serializing_if = "Option::is_none")]
369 pub rfq_enabled: Option<bool>,
370 #[serde(skip_serializing_if = "Option::is_none")]
371 pub uma_resolution_status: Option<String>,
372 #[serde(skip_serializing_if = "Option::is_none")]
373 pub game_id: Option<String>,
374 #[serde(skip_serializing_if = "Option::is_none")]
375 pub sports_market_types: Option<Vec<String>>,
376 #[serde(skip_serializing_if = "Option::is_none")]
377 pub include_tag: Option<bool>,
378 #[serde(skip_serializing_if = "Option::is_none")]
379 pub locale: Option<String>,
380 #[serde(skip)]
384 pub max_markets: Option<u32>,
385}
386
387#[derive(Clone, Debug, Default, Serialize, Builder)]
389#[builder(setter(into, strip_option), default)]
390pub struct GetGammaEventsParams {
391 #[serde(skip_serializing_if = "Option::is_none")]
393 pub active: Option<bool>,
394 #[serde(skip_serializing_if = "Option::is_none")]
395 pub closed: Option<bool>,
396 #[serde(skip_serializing_if = "Option::is_none")]
398 pub archived: Option<bool>,
399 #[serde(skip_serializing_if = "Option::is_none")]
400 pub id: Option<Vec<u64>>,
401 #[serde(skip_serializing_if = "Option::is_none")]
402 pub slug: Option<Vec<String>>,
403 #[serde(skip_serializing_if = "Option::is_none")]
404 pub live: Option<bool>,
405 #[serde(skip_serializing_if = "Option::is_none")]
406 pub featured: Option<bool>,
407 #[serde(skip_serializing_if = "Option::is_none")]
408 pub cyom: Option<bool>,
409 #[serde(skip_serializing_if = "Option::is_none")]
410 pub title_search: Option<String>,
411 #[serde(skip_serializing_if = "Option::is_none")]
412 pub liquidity_min: Option<Decimal>,
413 #[serde(skip_serializing_if = "Option::is_none")]
414 pub liquidity_max: Option<Decimal>,
415 #[serde(skip_serializing_if = "Option::is_none")]
416 pub volume_min: Option<Decimal>,
417 #[serde(skip_serializing_if = "Option::is_none")]
418 pub volume_max: Option<Decimal>,
419 #[serde(skip_serializing_if = "Option::is_none")]
421 pub start_date_min: Option<String>,
422 #[serde(skip_serializing_if = "Option::is_none")]
424 pub start_date_max: Option<String>,
425 #[serde(skip_serializing_if = "Option::is_none")]
427 pub end_date_min: Option<String>,
428 #[serde(skip_serializing_if = "Option::is_none")]
430 pub end_date_max: Option<String>,
431 #[serde(skip_serializing_if = "Option::is_none")]
433 pub start_time_min: Option<String>,
434 #[serde(skip_serializing_if = "Option::is_none")]
436 pub start_time_max: Option<String>,
437 #[serde(skip_serializing_if = "Option::is_none")]
438 pub tag_id: Option<Vec<u64>>,
439 #[serde(skip_serializing_if = "Option::is_none")]
440 pub tag_slug: Option<String>,
441 #[serde(skip_serializing_if = "Option::is_none")]
442 pub exclude_tag_id: Option<Vec<u64>>,
443 #[serde(skip_serializing_if = "Option::is_none")]
444 pub related_tags: Option<bool>,
445 #[serde(skip_serializing_if = "Option::is_none")]
446 pub tag_match: Option<String>,
447 #[serde(skip_serializing_if = "Option::is_none")]
448 pub series_id: Option<Vec<u64>>,
449 #[serde(skip_serializing_if = "Option::is_none")]
450 pub game_id: Option<Vec<u64>>,
451 #[serde(skip_serializing_if = "Option::is_none")]
453 pub event_date: Option<String>,
454 #[serde(skip_serializing_if = "Option::is_none")]
455 pub event_week: Option<u32>,
456 #[serde(skip_serializing_if = "Option::is_none")]
457 pub featured_order: Option<bool>,
458 #[serde(skip_serializing_if = "Option::is_none")]
459 pub recurrence: Option<String>,
460 #[serde(skip_serializing_if = "Option::is_none")]
461 pub created_by: Option<Vec<String>>,
462 #[serde(skip_serializing_if = "Option::is_none")]
463 pub parent_event_id: Option<u64>,
464 #[serde(skip_serializing_if = "Option::is_none")]
465 pub include_children: Option<bool>,
466 #[serde(skip_serializing_if = "Option::is_none")]
467 pub partner_slug: Option<String>,
468 #[serde(skip_serializing_if = "Option::is_none")]
469 pub include_chat: Option<bool>,
470 #[serde(skip_serializing_if = "Option::is_none")]
471 pub include_template: Option<bool>,
472 #[serde(skip_serializing_if = "Option::is_none")]
473 pub include_best_lines: Option<bool>,
474 #[serde(skip_serializing_if = "Option::is_none")]
475 pub locale: Option<String>,
476 #[serde(skip_serializing_if = "Option::is_none")]
477 pub order: Option<String>,
478 #[serde(skip_serializing_if = "Option::is_none")]
479 pub ascending: Option<bool>,
480 #[serde(skip_serializing_if = "Option::is_none")]
481 pub limit: Option<u32>,
482 #[serde(skip_serializing_if = "Option::is_none")]
484 pub offset: Option<u32>,
485 #[serde(skip)]
487 pub max_events: Option<u32>,
488}
489
490impl GetGammaMarketsParams {
491 pub fn validate_keyset(&self) -> Result<(), String> {
493 validate_limit(self.limit, 100, "market")?;
494 validate_non_empty_values(self.id.as_deref(), "id")?;
495 validate_non_empty_list(self.slug.as_deref(), "slug")?;
496 validate_non_empty_list(self.clob_token_ids.as_deref(), "clob_token_ids")?;
497 validate_non_empty_list(self.condition_ids.as_deref(), "condition_ids")?;
498 validate_non_empty_list(self.question_ids.as_deref(), "question_ids")?;
499 validate_non_empty_list(self.market_maker_address.as_deref(), "market_maker_address")?;
500 validate_non_empty_values(self.tag_id.as_deref(), "tag_id")?;
501 validate_non_empty_list(self.sports_market_types.as_deref(), "sports_market_types")?;
502
503 if self
504 .condition_ids
505 .as_ref()
506 .is_some_and(|ids| ids.len() > 100)
507 {
508 return Err("condition_ids accepts at most 100 values".to_string());
509 }
510
511 validate_decimal_bounds(
512 self.liquidity_num_min,
513 self.liquidity_num_max,
514 "liquidity_num",
515 )?;
516 validate_decimal_bounds(self.volume_num_min, self.volume_num_max, "volume_num")?;
517 validate_date_bounds(
518 self.start_date_min.as_deref(),
519 self.start_date_max.as_deref(),
520 "start_date",
521 )?;
522 validate_date_bounds(
523 self.end_date_min.as_deref(),
524 self.end_date_max.as_deref(),
525 "end_date",
526 )?;
527 validate_non_empty_string(self.order.as_deref(), "order")?;
528 validate_non_empty_string(self.tag_match.as_deref(), "tag_match")?;
529 validate_non_empty_string(
530 self.uma_resolution_status.as_deref(),
531 "uma_resolution_status",
532 )?;
533 validate_non_empty_string(self.game_id.as_deref(), "game_id")?;
534 validate_non_empty_string(self.locale.as_deref(), "locale")
535 }
536}
537
538impl GetGammaEventsParams {
539 pub fn validate_keyset(&self) -> Result<(), String> {
541 validate_limit(self.limit, 500, "event")?;
542 validate_non_empty_values(self.id.as_deref(), "id")?;
543 validate_non_empty_list(self.slug.as_deref(), "slug")?;
544 validate_non_empty_values(self.tag_id.as_deref(), "tag_id")?;
545 validate_non_empty_values(self.exclude_tag_id.as_deref(), "exclude_tag_id")?;
546 validate_non_empty_values(self.series_id.as_deref(), "series_id")?;
547 validate_non_empty_values(self.game_id.as_deref(), "game_id")?;
548 validate_non_empty_list(self.created_by.as_deref(), "created_by")?;
549
550 if let (Some(tag_ids), Some(excluded_ids)) = (&self.tag_id, &self.exclude_tag_id) {
551 let tag_ids: AHashSet<u64> = tag_ids.iter().copied().collect();
552 if excluded_ids.iter().any(|id| tag_ids.contains(id)) {
553 return Err("tag_id and exclude_tag_id cannot overlap".to_string());
554 }
555 }
556
557 validate_decimal_bounds(self.liquidity_min, self.liquidity_max, "liquidity")?;
558 validate_decimal_bounds(self.volume_min, self.volume_max, "volume")?;
559 validate_date_bounds(
560 self.start_date_min.as_deref(),
561 self.start_date_max.as_deref(),
562 "start_date",
563 )?;
564 validate_date_bounds(
565 self.end_date_min.as_deref(),
566 self.end_date_max.as_deref(),
567 "end_date",
568 )?;
569 validate_date_bounds(
570 self.start_time_min.as_deref(),
571 self.start_time_max.as_deref(),
572 "start_time",
573 )?;
574 validate_date_value(self.event_date.as_deref(), "event_date")?;
575 validate_non_empty_string(self.order.as_deref(), "order")?;
576 validate_non_empty_string(self.title_search.as_deref(), "title_search")?;
577 validate_non_empty_string(self.tag_slug.as_deref(), "tag_slug")?;
578 validate_non_empty_string(self.tag_match.as_deref(), "tag_match")?;
579 validate_non_empty_string(self.recurrence.as_deref(), "recurrence")?;
580 validate_non_empty_string(self.partner_slug.as_deref(), "partner_slug")?;
581 validate_non_empty_string(self.locale.as_deref(), "locale")
582 }
583}
584
585fn validate_limit(limit: Option<u32>, ceiling: u32, endpoint: &str) -> Result<(), String> {
586 if let Some(limit) = limit
587 && !(1..=ceiling).contains(&limit)
588 {
589 return Err(format!(
590 "{endpoint} limit must be between 1 and {ceiling}, was {limit}"
591 ));
592 }
593 Ok(())
594}
595
596fn validate_non_empty_list(values: Option<&[String]>, name: &str) -> Result<(), String> {
597 if let Some(values) = values
598 && (values.is_empty() || values.iter().any(|value| value.trim().is_empty()))
599 {
600 return Err(format!("{name} must contain non-empty values"));
601 }
602 Ok(())
603}
604
605fn validate_non_empty_values<T>(values: Option<&[T]>, name: &str) -> Result<(), String> {
606 if values.is_some_and(<[T]>::is_empty) {
607 return Err(format!("{name} must contain at least one value"));
608 }
609 Ok(())
610}
611
612fn validate_decimal_bounds(
613 min: Option<Decimal>,
614 max: Option<Decimal>,
615 name: &str,
616) -> Result<(), String> {
617 if let (Some(min), Some(max)) = (min, max)
618 && min > max
619 {
620 return Err(format!("{name}_min cannot exceed {name}_max"));
621 }
622 Ok(())
623}
624
625fn validate_date_bounds(min: Option<&str>, max: Option<&str>, name: &str) -> Result<(), String> {
626 let min = parse_date_value(min, &format!("{name}_min"))?;
627 let max = parse_date_value(max, &format!("{name}_max"))?;
628 if let (Some(min), Some(max)) = (min, max)
629 && min > max
630 {
631 return Err(format!("{name}_min cannot exceed {name}_max"));
632 }
633 Ok(())
634}
635
636fn validate_date_value(value: Option<&str>, name: &str) -> Result<(), String> {
637 parse_date_value(value, name).map(|_| ())
638}
639
640fn parse_date_value(value: Option<&str>, name: &str) -> Result<Option<Timestamp>, String> {
641 value
642 .map(|value| {
643 if value.as_bytes().get(10) == Some(&b'T') {
644 return value
645 .parse::<Timestamp>()
646 .map_err(|_| format!("{name} must be an ISO 8601 date or RFC 3339 date-time"));
647 }
648
649 if value.len() == 10
650 && value.as_bytes().get(4) == Some(&b'-')
651 && value.as_bytes().get(7) == Some(&b'-')
652 {
653 return value
654 .parse::<Date>()
655 .and_then(|date| Offset::UTC.to_timestamp(date.at(0, 0, 0, 0)))
656 .map_err(|_| format!("{name} must be an ISO 8601 date or RFC 3339 date-time"));
657 }
658
659 Err(format!(
660 "{name} must be an ISO 8601 date or RFC 3339 date-time"
661 ))
662 })
663 .transpose()
664}
665
666fn validate_non_empty_string(value: Option<&str>, name: &str) -> Result<(), String> {
667 if value.is_some_and(|value| value.trim().is_empty()) {
668 return Err(format!("{name} cannot be empty"));
669 }
670 Ok(())
671}
672
673#[derive(Clone, Debug, Default, Serialize, Builder)]
675#[builder(setter(into, strip_option), default)]
676pub struct GetSearchParams {
677 #[serde(skip_serializing_if = "Option::is_none")]
679 pub q: Option<String>,
680 #[serde(skip_serializing_if = "Option::is_none")]
682 pub events_status: Option<String>,
683 #[serde(skip_serializing_if = "Option::is_none")]
685 pub events_tag: Option<String>,
686 #[serde(skip_serializing_if = "Option::is_none")]
688 pub sort: Option<String>,
689 #[serde(skip_serializing_if = "Option::is_none")]
690 pub ascending: Option<bool>,
691 #[serde(skip_serializing_if = "Option::is_none")]
692 pub limit_per_type: Option<u32>,
693 #[serde(skip_serializing_if = "Option::is_none")]
694 pub page: Option<u32>,
695 #[serde(skip_serializing_if = "Option::is_none")]
696 pub keep_closed_markets: Option<bool>,
697}
698
699#[derive(Clone, Debug, Deserialize)]
701pub struct PaginatedResponse<T> {
702 pub data: Vec<T>,
703 pub next_cursor: Option<String>,
704}
705
706#[cfg(test)]
707mod tests {
708 use rstest::rstest;
709 use rust_decimal_macros::dec;
710 use serde::de::value::{Error as ValueError, MapDeserializer};
711
712 use super::*;
713 use crate::{
714 common::enums::{PolymarketOrderSide, PolymarketOrderType},
715 http::models::{PolymarketOpenOrder, PolymarketTradeReport},
716 };
717
718 const MAX_ALLOWANCE: &str =
719 "115792089237316195423570985008687907853269984665640564039457584007913129639935";
720
721 struct OversizedSizeHint<I>(I);
722
723 impl<I> Iterator for OversizedSizeHint<I>
724 where
725 I: Iterator,
726 {
727 type Item = I::Item;
728
729 fn next(&mut self) -> Option<Self::Item> {
730 self.0.next()
731 }
732
733 fn size_hint(&self) -> (usize, Option<usize>) {
734 (usize::MAX, Some(usize::MAX))
735 }
736 }
737
738 fn load<T: serde::de::DeserializeOwned>(filename: &str) -> T {
739 let path = format!("test_data/{filename}");
740 let content = std::fs::read_to_string(path).expect("Failed to read test data");
741 serde_json::from_str(&content).expect("Failed to parse test data")
742 }
743
744 #[rstest]
745 fn test_paginated_orders_page() {
746 let page: PaginatedResponse<PolymarketOpenOrder> = load("http_open_orders_page.json");
747
748 assert_eq!(page.data.len(), 2);
749 assert_eq!(page.next_cursor.as_deref(), Some("LTE="));
750 assert_eq!(page.data[0].side, PolymarketOrderSide::Buy);
751 assert_eq!(page.data[1].side, PolymarketOrderSide::Sell);
752 }
753
754 #[rstest]
755 fn test_paginated_trades_page() {
756 let page: PaginatedResponse<PolymarketTradeReport> = load("http_trades_page.json");
757
758 assert_eq!(page.data.len(), 1);
759 assert_eq!(page.next_cursor.as_deref(), Some("LTE="));
760 assert_eq!(page.data[0].id, "trade-0x001");
761 }
762
763 #[rstest]
764 fn test_balance_allowance_with_allowance() {
765 let ba: BalanceAllowance = load("http_balance_allowance_collateral.json");
766
767 assert_eq!(ba.balance, dec!(37_506_152));
768 assert!(ba.allowance.is_none());
769 assert_eq!(
770 ba.allowances,
771 std::collections::HashMap::from([
772 (
773 "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(),
774 MAX_ALLOWANCE.to_string(),
775 ),
776 (
777 "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(),
778 MAX_ALLOWANCE.to_string(),
779 ),
780 (
781 "0xcccccccccccccccccccccccccccccccccccccccc".to_string(),
782 MAX_ALLOWANCE.to_string(),
783 ),
784 ])
785 );
786 }
787
788 #[rstest]
789 fn test_balance_allowance_conditional() {
790 let ba: BalanceAllowance = load("http_balance_allowance_conditional.json");
791
792 assert_eq!(ba.balance, Decimal::ZERO);
793 assert!(ba.allowance.is_none());
794 assert_eq!(ba.allowances.len(), 3);
795 assert!(ba.allowances.values().all(|value| value == MAX_ALLOWANCE));
796 }
797
798 #[rstest]
799 fn test_balance_allowance_rejects_missing_allowances() {
800 let result = serde_json::from_str::<BalanceAllowance>(include_str!(concat!(
801 env!("CARGO_MANIFEST_DIR"),
802 "/test_data/http_balance_allowance_no_allowance.json"
803 )));
804
805 assert!(result.unwrap_err().to_string().contains("missing field"));
806 }
807
808 #[rstest]
809 fn test_balance_allowance_rejects_legacy_singular_without_allowances() {
810 let result =
811 serde_json::from_str::<BalanceAllowance>(r#"{"balance":"250.5","allowance":"1000"}"#);
812
813 assert!(
814 result
815 .unwrap_err()
816 .to_string()
817 .contains("legacy singular `allowance`")
818 );
819 }
820
821 #[rstest]
822 fn test_balance_allowance_rejects_conflicting_singular_and_plural_allowances() {
823 let result = serde_json::from_str::<BalanceAllowance>(
824 r#"{
825 "balance":"250.5",
826 "allowance":"0",
827 "allowances":{
828 "0xe111180000d2663c0091e4f400237545b87b996b":"115792089237316195423570985008687907853269984665640564039457584007913129639935"
829 }
830 }"#,
831 );
832
833 assert!(
834 result
835 .unwrap_err()
836 .to_string()
837 .contains("legacy singular `allowance`")
838 );
839 }
840
841 #[rstest]
842 fn test_balance_allowance_accepts_null_legacy_marker() {
843 let result = serde_json::from_str::<BalanceAllowance>(
844 r#"{
845 "balance":"250.5",
846 "allowance":null,
847 "allowances":{
848 "0xe111180000d2663c0091e4f400237545b87b996b":"1000"
849 }
850 }"#,
851 )
852 .unwrap();
853
854 assert!(result.allowance.is_none());
855 assert_eq!(result.allowances.len(), 1);
856 }
857
858 #[rstest]
859 fn test_balance_allowance_rejects_duplicate_spender() {
860 let duplicate = serde_json::from_str::<BalanceAllowance>(
861 r#"{
862 "balance":"250.5",
863 "allowances":{
864 "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa":"0",
865 "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa":"115792089237316195423570985008687907853269984665640564039457584007913129639935"
866 }
867 }"#,
868 )
869 .expect_err("duplicate spender evidence must be rejected before map construction");
870
871 assert!(duplicate.to_string().contains("duplicate spender"));
872 }
873
874 #[rstest]
875 fn test_balance_allowance_rejects_case_variant_spender_alias() {
876 let duplicate = serde_json::from_str::<BalanceAllowance>(
877 r#"{
878 "balance":"250.5",
879 "allowances":{
880 "0xada2005600dec949baf300f4c6120000bdb6eaab":"0",
881 "0xadA2005600Dec949baf300f4C6120000bDB6eAab":"115792089237316195423570985008687907853269984665640564039457584007913129639935"
882 }
883 }"#,
884 )
885 .expect_err("case variants of one EVM spender must be rejected as duplicates");
886
887 assert!(duplicate.to_string().contains("duplicate spender"));
888 }
889
890 #[rstest]
891 fn test_balance_allowance_rejects_malformed_spender() {
892 let malformed = serde_json::from_str::<BalanceAllowance>(
893 r#"{
894 "balance":"250.5",
895 "allowances":{
896 "exchange":"1000"
897 }
898 }"#,
899 )
900 .expect_err("allowance spender must be an EVM address");
901
902 assert!(malformed.to_string().contains("invalid spender"));
903 }
904
905 #[rstest]
906 #[case::missing_prefix("ada2005600dec949baf300f4c6120000bdb6eaab")]
907 #[case::uppercase_prefix("0Xada2005600dec949baf300f4c6120000bdb6eaab")]
908 #[case::invalid_checksum("0xAdA2005600Dec949baf300f4C6120000bDB6eAab")]
909 fn test_balance_allowance_rejects_noncanonical_spender(#[case] spender: &str) {
910 let payload = format!(r#"{{"balance":"250.5","allowances":{{"{spender}":"1000"}}}}"#,);
911
912 let result = serde_json::from_str::<BalanceAllowance>(&payload);
913
914 assert!(result.unwrap_err().to_string().contains("invalid spender"));
915 }
916
917 #[rstest]
918 fn test_balance_allowance_preserves_checksummed_spender() {
919 let spender = "0xadA2005600Dec949baf300f4C6120000bDB6eAab";
920 let payload = format!(r#"{{"balance":"250.5","allowances":{{"{spender}":"1000"}}}}"#,);
921
922 let balance_allowance = serde_json::from_str::<BalanceAllowance>(&payload).unwrap();
923
924 assert_eq!(
925 balance_allowance.allowances.get(spender),
926 Some(&"1000".to_string())
927 );
928 }
929
930 #[rstest]
931 fn test_spender_allowances_ignores_untrusted_size_hint() {
932 let spender = "0xada2005600dec949baf300f4c6120000bdb6eaab";
933 let entries = [(spender, "1000")];
934 let deserializer =
935 MapDeserializer::<_, ValueError>::new(OversizedSizeHint(entries.into_iter()));
936
937 let allowances = deserialize_spender_allowances(deserializer).unwrap();
938
939 assert_eq!(allowances.get(spender).map(String::as_str), Some("1000"));
940 }
941
942 #[rstest]
943 fn test_order_response_success() {
944 let resp: OrderResponse = load("http_order_response_ok.json");
945
946 assert!(resp.success);
947 assert_eq!(
948 resp.order_id.as_deref(),
949 Some("0x1111111111111111111111111111111111111111111111111111111111111111")
950 );
951 assert_eq!(resp.status, Some(OrderResponseStatus::Delayed));
952 assert!(resp.making_amount.is_none());
953 assert!(resp.taking_amount.is_none());
954 assert!(resp.transaction_hashes.is_none());
955 assert!(resp.trade_ids.is_none());
956 assert_eq!(resp.error_msg.as_deref(), Some(""));
957 }
958
959 #[rstest]
960 fn test_order_response_failure() {
961 let resp: OrderResponse = load("http_order_response_failed.json");
963
964 assert!(!resp.success);
965 assert!(resp.order_id.is_none());
966 assert!(resp.status.is_none());
967 assert!(resp.making_amount.is_none());
968 assert!(resp.taking_amount.is_none());
969 assert!(resp.transaction_hashes.is_none());
970 assert!(resp.trade_ids.is_none());
971 assert_eq!(resp.error_msg.as_deref(), Some("Insufficient balance"));
972 }
973
974 #[rstest]
975 fn test_order_response_empty_status() {
976 let json = r#"{
978 "success":true,
979 "orderID":"",
980 "status":"",
981 "makingAmount":"",
982 "takingAmount":"",
983 "errorMsg":"post-only mode"
984 }"#;
985 let resp: OrderResponse = serde_json::from_str(json).unwrap();
986
987 assert!(resp.success);
988 assert_eq!(resp.order_id.as_deref(), Some(""));
989 assert!(resp.status.is_none());
990 assert!(resp.making_amount.is_none());
991 assert!(resp.taking_amount.is_none());
992 assert!(resp.transaction_hashes.is_none());
993 assert!(resp.trade_ids.is_none());
994 assert_eq!(resp.error_msg.as_deref(), Some("post-only mode"));
995 }
996
997 #[rstest]
998 fn test_order_response_unmatched_status() {
999 let json = r#"{
1001 "success":false,
1002 "orderID":"",
1003 "status":"unmatched",
1004 "makingAmount":"",
1005 "takingAmount":"",
1006 "errorMsg":"placement failed"
1007 }"#;
1008 let resp: OrderResponse = serde_json::from_str(json).unwrap();
1009
1010 assert!(!resp.success);
1011 assert_eq!(resp.order_id.as_deref(), Some(""));
1012 assert_eq!(resp.status, Some(OrderResponseStatus::Unmatched));
1013 assert!(resp.making_amount.is_none());
1014 assert!(resp.taking_amount.is_none());
1015 assert!(resp.transaction_hashes.is_none());
1016 assert!(resp.trade_ids.is_none());
1017 assert_eq!(resp.error_msg.as_deref(), Some("placement failed"));
1018 }
1019
1020 #[rstest]
1021 fn test_order_response_matched_fields() {
1022 let resp: OrderResponse = load("http_order_response_async_exec.json");
1025
1026 assert!(resp.success);
1027 assert_eq!(
1028 resp.order_id.as_deref(),
1029 Some("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef12")
1030 );
1031 assert_eq!(resp.status, Some(OrderResponseStatus::Matched));
1032 assert_eq!(resp.making_amount, Some(dec!(100_000_000)));
1033 assert_eq!(resp.taking_amount, Some(dec!(200_000_000)));
1034 assert_eq!(
1035 resp.transaction_hashes.as_deref(),
1036 Some(
1037 &[
1038 "0xaaaa000000000000000000000000000000000000000000000000000000000000"
1039 .to_string(),
1040 "0xbbbb000000000000000000000000000000000000000000000000000000000000"
1041 .to_string(),
1042 ][..]
1043 )
1044 );
1045 assert_eq!(
1046 resp.trade_ids.as_deref(),
1047 Some(&["trade-0x001".to_string(), "trade-0x002".to_string()][..])
1048 );
1049 assert!(resp.error_msg.is_none());
1050 }
1051
1052 #[rstest]
1053 fn test_order_response_trade_ids_without_transaction_hashes() {
1054 let resp: OrderResponse = load("http_order_response_trade_ids_only.json");
1056
1057 assert!(resp.success);
1058 assert_eq!(
1059 resp.order_id.as_deref(),
1060 Some("0xfedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210fe")
1061 );
1062 assert_eq!(resp.status, Some(OrderResponseStatus::Matched));
1063 assert_eq!(resp.making_amount, Some(dec!(25_000_000)));
1064 assert_eq!(resp.taking_amount, Some(dec!(50_000_000)));
1065 assert!(resp.transaction_hashes.is_none());
1066 assert_eq!(
1067 resp.trade_ids.as_deref(),
1068 Some(&["trade-0x101".to_string(), "trade-0x102".to_string()][..])
1069 );
1070 assert!(resp.error_msg.is_none());
1071 }
1072
1073 #[rstest]
1074 fn test_batch_order_response_legs() {
1075 let resps: Vec<OrderResponse> = load("http_batch_order_response.json");
1077
1078 assert_eq!(resps.len(), 2);
1079
1080 for (index, resp) in resps.iter().enumerate() {
1081 assert!(resp.success);
1082 assert_eq!(
1083 resp.order_id.as_deref(),
1084 Some(if index == 0 {
1085 "0x1111111111111111111111111111111111111111111111111111111111111111"
1086 } else {
1087 "0x2222222222222222222222222222222222222222222222222222222222222222"
1088 })
1089 );
1090 assert_eq!(resp.status, Some(OrderResponseStatus::Delayed));
1091 assert!(resp.making_amount.is_none());
1092 assert!(resp.taking_amount.is_none());
1093 assert!(resp.transaction_hashes.is_none());
1094 assert!(resp.trade_ids.is_none());
1095 assert_eq!(resp.error_msg.as_deref(), Some(""));
1096 }
1097 }
1098
1099 #[rstest]
1100 fn test_cancel_response_ok() {
1101 let resp: CancelResponse = load("http_cancel_response_ok.json");
1102
1103 assert_eq!(resp.canceled.len(), 1);
1104 assert!(resp.not_canceled.is_empty());
1105 }
1106
1107 #[rstest]
1108 fn test_cancel_response_failed() {
1109 let resp: CancelResponse = load("http_cancel_response_failed.json");
1110
1111 assert!(resp.canceled.is_empty());
1112 assert_eq!(resp.not_canceled.len(), 1);
1113 let reason = resp.not_canceled.values().next().and_then(|v| v.as_deref());
1114 assert_eq!(reason, Some("already canceled or matched"));
1115 }
1116
1117 #[rstest]
1118 fn test_batch_cancel_response() {
1119 let resp: BatchCancelResponse = load("http_batch_cancel_response.json");
1120
1121 assert_eq!(resp.canceled.len(), 2);
1122 assert!(resp.canceled[0].contains("1111"));
1123 assert!(resp.canceled[1].contains("2222"));
1124 assert_eq!(resp.not_canceled.len(), 1);
1125 let reason = resp.not_canceled.values().next().and_then(|v| v.as_deref());
1126 assert_eq!(reason, Some("already canceled or matched"));
1127 }
1128
1129 #[rstest]
1130 fn test_cancel_response_merge_preserves_canceled_and_not_canceled_results() {
1131 let mut merged = CancelResponse {
1132 canceled: vec!["order-1".to_string()],
1133 not_canceled: AHashMap::from_iter([(
1134 "order-2".to_string(),
1135 Some("already canceled".to_string()),
1136 )]),
1137 };
1138 merged.merge(CancelResponse {
1139 canceled: vec!["order-3".to_string()],
1140 not_canceled: AHashMap::from_iter([(
1141 "order-4".to_string(),
1142 Some("order not found".to_string()),
1143 )]),
1144 });
1145
1146 assert_eq!(
1147 merged.canceled,
1148 vec!["order-1".to_string(), "order-3".to_string()]
1149 );
1150 assert_eq!(
1151 merged.not_canceled,
1152 AHashMap::from_iter([
1153 ("order-2".to_string(), Some("already canceled".to_string())),
1154 ("order-4".to_string(), Some("order not found".to_string())),
1155 ])
1156 );
1157 }
1158
1159 #[rstest]
1160 fn test_asset_type_serializes_screaming_snake() {
1161 assert_eq!(
1162 serde_json::to_string(&AssetType::Collateral).unwrap(),
1163 "\"COLLATERAL\""
1164 );
1165 assert_eq!(
1166 serde_json::to_string(&AssetType::Conditional).unwrap(),
1167 "\"CONDITIONAL\""
1168 );
1169 }
1170
1171 #[rstest]
1172 fn test_asset_type_deserializes() {
1173 assert_eq!(
1174 serde_json::from_str::<AssetType>("\"COLLATERAL\"").unwrap(),
1175 AssetType::Collateral
1176 );
1177 assert_eq!(
1178 serde_json::from_str::<AssetType>("\"CONDITIONAL\"").unwrap(),
1179 AssetType::Conditional
1180 );
1181 }
1182
1183 #[rstest]
1184 fn test_get_orders_params_skips_none() {
1185 let params = GetOrdersParams::default();
1186 let json = serde_json::to_string(¶ms).unwrap();
1187 assert_eq!(json, "{}");
1188 }
1189
1190 #[rstest]
1191 fn test_get_orders_params_serializes_set_fields() {
1192 let params = GetOrdersParams {
1193 market: Some("0xmarket".to_string()),
1194 asset_id: None,
1195 next_cursor: Some("MA==".to_string()),
1196 ..Default::default()
1197 };
1198 let json = serde_json::to_string(¶ms).unwrap();
1199 assert!(json.contains("\"market\""));
1200 assert!(json.contains("\"next_cursor\""));
1201 assert!(!json.contains("\"asset_id\""));
1202 }
1203
1204 #[rstest]
1205 fn test_get_orders_params_id_filter() {
1206 let params = GetOrdersParams {
1207 id: Some("0xorder123".to_string()),
1208 ..Default::default()
1209 };
1210 let json = serde_json::to_string(¶ms).unwrap();
1211 assert!(json.contains("\"id\""));
1212 assert!(json.contains("0xorder123"));
1213 }
1214
1215 #[rstest]
1216 fn test_get_gamma_markets_params_slug() {
1217 let params = GetGammaMarketsParams {
1218 slug: Some(vec!["btc-updown-15m-1741500000".to_string()]),
1219 ..Default::default()
1220 };
1221 let json = serde_json::to_string(¶ms).unwrap();
1222 assert!(json.contains("\"slug\""));
1223 assert!(json.contains("btc-updown-15m-1741500000"));
1224 assert!(!json.contains("\"active\""));
1225 }
1226
1227 #[rstest]
1228 fn test_get_gamma_markets_params_skips_none_slug() {
1229 let params = GetGammaMarketsParams {
1230 active: Some(true),
1231 ..Default::default()
1232 };
1233 let json = serde_json::to_string(¶ms).unwrap();
1234 assert!(!json.contains("\"slug\""));
1235 assert!(json.contains("\"active\""));
1236 }
1237
1238 #[rstest]
1239 fn test_get_gamma_markets_params_new_filter_fields() {
1240 let params = GetGammaMarketsParams {
1241 volume_num_min: Some(dec!(1000.0)),
1242 tag_id: Some(vec![123]),
1243 end_date_min: Some("2025-06-01T00:00:00Z".to_string()),
1244 ..Default::default()
1245 };
1246 let json = serde_json::to_string(¶ms).unwrap();
1247 assert!(json.contains("\"volume_num_min\":\"1000.0\""));
1248 assert!(json.contains("\"tag_id\":[123]"));
1249 assert!(json.contains("\"end_date_min\":\"2025-06-01T00:00:00Z\""));
1250 assert!(!json.contains("\"active\""));
1251 assert!(!json.contains("\"archived\""));
1252 }
1253
1254 #[rstest]
1255 fn test_get_gamma_markets_params_condition_ids() {
1256 let params = GetGammaMarketsParams {
1257 condition_ids: Some(vec!["0xcond1".to_string(), "0xcond2".to_string()]),
1258 liquidity_num_min: Some(dec!(500.0)),
1259 ..Default::default()
1260 };
1261 let json = serde_json::to_string(¶ms).unwrap();
1262 assert!(json.contains("\"condition_ids\":[\"0xcond1\",\"0xcond2\"]"));
1263 assert!(json.contains("\"liquidity_num_min\":\"500.0\""));
1264 }
1265
1266 #[rstest]
1267 fn test_get_trades_params_skips_none() {
1268 let params = GetTradesParams::default();
1269 let json = serde_json::to_string(¶ms).unwrap();
1270 assert_eq!(json, "{}");
1271 }
1272
1273 #[rstest]
1274 fn test_post_order_params_skips_post_only_when_false() {
1275 let params = PostOrderParams {
1276 order_type: PolymarketOrderType::GTC,
1277 post_only: false,
1278 };
1279 let json = serde_json::to_string(¶ms).unwrap();
1280 assert!(!json.contains("post_only"));
1281 assert!(!json.contains("postOnly"));
1282 }
1283
1284 #[rstest]
1285 fn test_post_order_params_includes_post_only_when_true() {
1286 let params = PostOrderParams {
1287 order_type: PolymarketOrderType::GTC,
1288 post_only: true,
1289 };
1290 let json = serde_json::to_string(¶ms).unwrap();
1291 assert!(json.contains("postOnly"));
1292 }
1293}