Skip to main content

polyoxide_clob/api/
rewards.rs

1use polyoxide_core::{HttpClient, QueryBuilder};
2use serde::{Deserialize, Serialize};
3
4use crate::{
5    account::{Credentials, Signer, Wallet},
6    error::ClobError,
7    request::{AuthMode, Request},
8    types::SignatureType,
9};
10
11/// Rewards namespace for liquidity reward operations
12#[derive(Clone)]
13pub struct Rewards {
14    pub(crate) http_client: HttpClient,
15    pub(crate) wallet: Wallet,
16    pub(crate) credentials: Credentials,
17    pub(crate) signer: Signer,
18    pub(crate) chain_id: u64,
19    pub(crate) signature_type: SignatureType,
20}
21
22impl Rewards {
23    fn l2_auth(&self) -> AuthMode {
24        AuthMode::L2 {
25            address: self.wallet.address(),
26            credentials: self.credentials.clone(),
27            signer: self.signer.clone(),
28        }
29    }
30
31    /// Get user earnings for a specific day (`GET /rewards/user`).
32    ///
33    /// `date` must be in `YYYY-MM-DD` format (required by the API). The
34    /// `signature_type` query parameter is taken from the client configuration.
35    pub fn earnings(&self, date: impl Into<String>) -> UserEarningsRequest {
36        UserEarningsRequest {
37            request: Request::get(
38                self.http_client.clone(),
39                "/rewards/user",
40                self.l2_auth(),
41                self.chain_id,
42            )
43            .query("date", date.into())
44            .query("signature_type", self.signature_type as u8),
45        }
46    }
47
48    /// Get user total earnings for a specific day (`GET /rewards/user/total`).
49    ///
50    /// `date` must be in `YYYY-MM-DD` format (required by the API). The endpoint
51    /// returns an array of totals grouped by asset address.
52    pub fn total_earnings(&self, date: impl Into<String>) -> UserTotalEarningsRequest {
53        UserTotalEarningsRequest {
54            request: Request::get(
55                self.http_client.clone(),
56                "/rewards/user/total",
57                self.l2_auth(),
58                self.chain_id,
59            )
60            .query("date", date.into())
61            .query("signature_type", self.signature_type as u8),
62        }
63    }
64
65    /// Get user reward percentages (`GET /rewards/user/percentages`).
66    pub fn percentages(&self) -> UserPercentagesRequest {
67        UserPercentagesRequest {
68            request: Request::get(
69                self.http_client.clone(),
70                "/rewards/user/percentages",
71                self.l2_auth(),
72                self.chain_id,
73            )
74            .query("signature_type", self.signature_type as u8),
75        }
76    }
77
78    /// Get user earnings broken down by market (`GET /rewards/user/markets`).
79    ///
80    /// Returns a paginated envelope; the per-market earnings are in `data`.
81    /// Pages hold 100 items by default (max 500); feed `next_cursor` back via
82    /// [`ListUserRewardMarkets::next_cursor`] until it reads `"LTE="`.
83    pub fn market_earnings(&self) -> ListUserRewardMarkets {
84        ListUserRewardMarkets {
85            request: Request::get(
86                self.http_client.clone(),
87                "/rewards/user/markets",
88                self.l2_auth(),
89                self.chain_id,
90            )
91            .query("signature_type", self.signature_type as u8),
92        }
93    }
94
95    /// The unauthenticated subset of the rewards API.
96    fn public(&self) -> PublicRewards {
97        PublicRewards {
98            http_client: self.http_client.clone(),
99            chain_id: self.chain_id,
100        }
101    }
102
103    /// Get currently active reward markets (`GET /rewards/markets/current`).
104    ///
105    /// Returns a paginated envelope (`data`, `next_cursor`, `limit`, `count`);
106    /// the reward markets are in the `data` field. Also available without an
107    /// account via [`Clob::public_rewards`](crate::Clob::public_rewards).
108    pub fn current_markets(&self) -> ListRewardMarkets {
109        self.public().current_markets()
110    }
111
112    /// Get rewards for a specific market (`GET /rewards/markets/{condition_id}`).
113    ///
114    /// Also available without an account via
115    /// [`Clob::public_rewards`](crate::Clob::public_rewards).
116    pub fn market(&self, condition_id: impl Into<String>) -> RewardMarketRequest {
117        self.public().market(condition_id)
118    }
119
120    /// Search active markets with reward configurations
121    /// (`GET /rewards/markets/multi`).
122    ///
123    /// Also available without an account via
124    /// [`Clob::public_rewards`](crate::Clob::public_rewards).
125    pub fn multi_markets(&self) -> ListMultiRewardMarkets {
126        self.public().multi_markets()
127    }
128
129    /// Get current rebated fees for a maker on a given date
130    /// (`GET /rebates/current`).
131    ///
132    /// Also available without an account via
133    /// [`Clob::public_rewards`](crate::Clob::public_rewards).
134    pub fn current_rebates(
135        &self,
136        date: impl Into<String>,
137        maker_address: impl Into<String>,
138    ) -> Request<Vec<RebatedFees>> {
139        self.public().current_rebates(date, maker_address)
140    }
141}
142
143/// The unauthenticated subset of the rewards API.
144///
145/// `GET /rewards/markets/current`, `/rewards/markets/{condition_id}`,
146/// `/rewards/markets/multi`, and `/rebates/current` are documented as public
147/// upstream, so they are reachable from a client built without an account (for
148/// example via `Clob::public()`). The same methods are mirrored on
149/// [`Rewards`] for callers that already hold an authenticated namespace.
150#[derive(Clone)]
151pub struct PublicRewards {
152    pub(crate) http_client: HttpClient,
153    pub(crate) chain_id: u64,
154}
155
156impl PublicRewards {
157    /// Get currently active reward markets (`GET /rewards/markets/current`).
158    ///
159    /// Returns a paginated envelope (`data`, `next_cursor`, `limit`, `count`);
160    /// the reward markets are in the `data` field.
161    pub fn current_markets(&self) -> ListRewardMarkets {
162        ListRewardMarkets {
163            request: Request::get(
164                self.http_client.clone(),
165                "/rewards/markets/current",
166                AuthMode::None,
167                self.chain_id,
168            ),
169        }
170    }
171
172    /// Get rewards for a specific market (`GET /rewards/markets/{condition_id}`).
173    pub fn market(&self, condition_id: impl Into<String>) -> RewardMarketRequest {
174        RewardMarketRequest {
175            request: Request::get(
176                self.http_client.clone(),
177                format!(
178                    "/rewards/markets/{}",
179                    urlencoding::encode(&condition_id.into())
180                ),
181                AuthMode::None,
182                self.chain_id,
183            ),
184        }
185    }
186
187    /// Search active markets with reward configurations
188    /// (`GET /rewards/markets/multi`).
189    ///
190    /// Supports text search, tag filtering, numeric filters, and sorting.
191    /// Pages hold 100 items by default (max 500); a `next_cursor` of `"LTE="`
192    /// marks the last page.
193    pub fn multi_markets(&self) -> ListMultiRewardMarkets {
194        ListMultiRewardMarkets {
195            request: Request::get(
196                self.http_client.clone(),
197                "/rewards/markets/multi",
198                AuthMode::None,
199                self.chain_id,
200            ),
201        }
202    }
203
204    /// Get current rebated fees for a maker on a given date
205    /// (`GET /rebates/current`).
206    ///
207    /// `date` must be in `YYYY-MM-DD` format. Each entry carries the condition
208    /// ID, asset address, and the USDC amount rebated.
209    pub fn current_rebates(
210        &self,
211        date: impl Into<String>,
212        maker_address: impl Into<String>,
213    ) -> Request<Vec<RebatedFees>> {
214        Request::get(
215            self.http_client.clone(),
216            "/rebates/current",
217            AuthMode::None,
218            self.chain_id,
219        )
220        .query("date", date.into())
221        .query("maker_address", maker_address.into())
222    }
223}
224
225/// Sort direction for the rewards market listings.
226#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
227#[serde(rename_all = "UPPERCASE")]
228pub enum SortPosition {
229    /// Ascending.
230    Asc,
231    /// Descending.
232    Desc,
233}
234
235impl SortPosition {
236    /// Wire representation.
237    pub fn as_str(&self) -> &'static str {
238        match self {
239            Self::Asc => "ASC",
240            Self::Desc => "DESC",
241        }
242    }
243}
244
245impl std::fmt::Display for SortPosition {
246    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
247        f.write_str(self.as_str())
248    }
249}
250
251/// Sort field for [`Rewards::multi_markets`].
252#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
253#[serde(rename_all = "snake_case")]
254pub enum MultiMarketOrderBy {
255    /// Market ID.
256    MarketId,
257    /// Creation time.
258    CreatedAt,
259    /// 24-hour volume.
260    Volume24hr,
261    /// Current spread.
262    Spread,
263    /// Competitiveness score.
264    Competitiveness,
265    /// Maximum rewarded spread.
266    MaxSpread,
267    /// Minimum rewarded size.
268    MinSize,
269    /// Question text.
270    Question,
271    /// One-day price change.
272    OneDayPriceChange,
273    /// Reward rate per day.
274    RatePerDay,
275    /// Current price.
276    Price,
277    /// Market end date.
278    EndDate,
279    /// Market start date.
280    StartDate,
281    /// Reward program end date.
282    RewardEndDate,
283}
284
285impl MultiMarketOrderBy {
286    /// Wire representation.
287    pub fn as_str(&self) -> &'static str {
288        match self {
289            Self::MarketId => "market_id",
290            Self::CreatedAt => "created_at",
291            Self::Volume24hr => "volume_24hr",
292            Self::Spread => "spread",
293            Self::Competitiveness => "competitiveness",
294            Self::MaxSpread => "max_spread",
295            Self::MinSize => "min_size",
296            Self::Question => "question",
297            Self::OneDayPriceChange => "one_day_price_change",
298            Self::RatePerDay => "rate_per_day",
299            Self::Price => "price",
300            Self::EndDate => "end_date",
301            Self::StartDate => "start_date",
302            Self::RewardEndDate => "reward_end_date",
303        }
304    }
305}
306
307impl std::fmt::Display for MultiMarketOrderBy {
308    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
309        f.write_str(self.as_str())
310    }
311}
312
313/// Sort field for [`Rewards::market_earnings`].
314#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
315#[serde(rename_all = "snake_case")]
316pub enum UserRewardMarketOrderBy {
317    /// Maximum rewarded spread.
318    MaxSpread,
319    /// Minimum rewarded size.
320    MinSize,
321    /// Market end date.
322    EndDate,
323    /// Share of the market's reward pool earned.
324    EarningPercentage,
325    /// Reward rate per day.
326    RatePerDay,
327    /// Earnings for the day.
328    Earnings,
329    /// Current spread.
330    Spread,
331    /// Competitiveness score.
332    Competitiveness,
333    /// Question text.
334    Question,
335    /// Current price.
336    Price,
337    /// Market identifier.
338    Market,
339    /// 24-hour volume.
340    Volume24hr,
341}
342
343impl UserRewardMarketOrderBy {
344    /// Wire representation.
345    pub fn as_str(&self) -> &'static str {
346        match self {
347            Self::MaxSpread => "max_spread",
348            Self::MinSize => "min_size",
349            Self::EndDate => "end_date",
350            Self::EarningPercentage => "earning_percentage",
351            Self::RatePerDay => "rate_per_day",
352            Self::Earnings => "earnings",
353            Self::Spread => "spread",
354            Self::Competitiveness => "competitiveness",
355            Self::Question => "question",
356            Self::Price => "price",
357            Self::Market => "market",
358            Self::Volume24hr => "volume_24hr",
359        }
360    }
361}
362
363impl std::fmt::Display for UserRewardMarketOrderBy {
364    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
365        f.write_str(self.as_str())
366    }
367}
368
369/// Request builder for `GET /rewards/user`.
370pub struct UserEarningsRequest {
371    request: Request<RewardEarnings>,
372}
373
374impl UserEarningsRequest {
375    /// Query earnings for a maker address other than the authenticated wallet.
376    pub fn maker_address(mut self, address: impl Into<String>) -> Self {
377        self.request = self.request.query("maker_address", address.into());
378        self
379    }
380
381    /// Restrict results to sponsored reward markets (default: `false`).
382    pub fn sponsored(mut self, sponsored: bool) -> Self {
383        self.request = self.request.query("sponsored", sponsored);
384        self
385    }
386
387    /// Continue from a pagination cursor.
388    pub fn next_cursor(mut self, cursor: impl Into<String>) -> Self {
389        self.request = self.request.query("next_cursor", cursor.into());
390        self
391    }
392
393    /// Execute the request.
394    pub async fn send(self) -> Result<RewardEarnings, ClobError> {
395        self.request.send().await
396    }
397}
398
399/// Request builder for `GET /rewards/user/total`.
400pub struct UserTotalEarningsRequest {
401    request: Request<Vec<RewardTotalEarnings>>,
402}
403
404impl UserTotalEarningsRequest {
405    /// Query totals for a maker address other than the authenticated wallet.
406    pub fn maker_address(mut self, address: impl Into<String>) -> Self {
407        self.request = self.request.query("maker_address", address.into());
408        self
409    }
410
411    /// Restrict results to sponsored reward markets (default: `false`).
412    pub fn sponsored(mut self, sponsored: bool) -> Self {
413        self.request = self.request.query("sponsored", sponsored);
414        self
415    }
416
417    /// Execute the request.
418    pub async fn send(self) -> Result<Vec<RewardTotalEarnings>, ClobError> {
419        self.request.send().await
420    }
421}
422
423/// Request builder for `GET /rewards/user/percentages`.
424pub struct UserPercentagesRequest {
425    request: Request<RewardPercentages>,
426}
427
428impl UserPercentagesRequest {
429    /// Query percentages for a maker address other than the authenticated wallet.
430    pub fn maker_address(mut self, address: impl Into<String>) -> Self {
431        self.request = self.request.query("maker_address", address.into());
432        self
433    }
434
435    /// Execute the request.
436    pub async fn send(self) -> Result<RewardPercentages, ClobError> {
437        self.request.send().await
438    }
439}
440
441/// Request builder for `GET /rewards/markets/current`.
442pub struct ListRewardMarkets {
443    request: Request<Paginated<RewardMarket>>,
444}
445
446impl ListRewardMarkets {
447    /// Restrict results to sponsored reward markets (default: `false`).
448    pub fn sponsored(mut self, sponsored: bool) -> Self {
449        self.request = self.request.query("sponsored", sponsored);
450        self
451    }
452
453    /// Continue from a pagination cursor; `"LTE="` marks the last page.
454    pub fn next_cursor(mut self, cursor: impl Into<String>) -> Self {
455        self.request = self.request.query("next_cursor", cursor.into());
456        self
457    }
458
459    /// Execute the request.
460    pub async fn send(self) -> Result<Paginated<RewardMarket>, ClobError> {
461        self.request.send().await
462    }
463}
464
465/// Request builder for `GET /rewards/markets/{condition_id}`.
466pub struct RewardMarketRequest {
467    request: Request<RewardMarket>,
468}
469
470impl RewardMarketRequest {
471    /// Restrict results to sponsored reward markets (default: `false`).
472    pub fn sponsored(mut self, sponsored: bool) -> Self {
473        self.request = self.request.query("sponsored", sponsored);
474        self
475    }
476
477    /// Continue from a pagination cursor.
478    pub fn next_cursor(mut self, cursor: impl Into<String>) -> Self {
479        self.request = self.request.query("next_cursor", cursor.into());
480        self
481    }
482
483    /// Execute the request.
484    pub async fn send(self) -> Result<RewardMarket, ClobError> {
485        self.request.send().await
486    }
487}
488
489/// Request builder for `GET /rewards/markets/multi`.
490pub struct ListMultiRewardMarkets {
491    request: Request<Paginated<RewardMarket>>,
492}
493
494impl ListMultiRewardMarkets {
495    /// Free-text search over market questions.
496    pub fn query_text(mut self, q: impl Into<String>) -> Self {
497        self.request = self.request.query("q", q.into());
498        self
499    }
500
501    /// Filter by tag slug.
502    pub fn tag_slug(mut self, slug: impl Into<String>) -> Self {
503        self.request = self.request.query("tag_slug", slug.into());
504        self
505    }
506
507    /// Filter by event ID.
508    pub fn event_id(mut self, event_id: impl Into<String>) -> Self {
509        self.request = self.request.query("event_id", event_id.into());
510        self
511    }
512
513    /// Filter by event title.
514    pub fn event_title(mut self, title: impl Into<String>) -> Self {
515        self.request = self.request.query("event_title", title.into());
516        self
517    }
518
519    /// Sort field.
520    pub fn order_by(mut self, order_by: MultiMarketOrderBy) -> Self {
521        self.request = self.request.query("order_by", order_by.as_str());
522        self
523    }
524
525    /// Sort direction.
526    pub fn position(mut self, position: SortPosition) -> Self {
527        self.request = self.request.query("position", position.as_str());
528        self
529    }
530
531    /// Minimum 24-hour volume.
532    pub fn min_volume_24hr(mut self, value: f64) -> Self {
533        self.request = self.request.query("min_volume_24hr", value);
534        self
535    }
536
537    /// Maximum 24-hour volume.
538    pub fn max_volume_24hr(mut self, value: f64) -> Self {
539        self.request = self.request.query("max_volume_24hr", value);
540        self
541    }
542
543    /// Minimum spread.
544    pub fn min_spread(mut self, value: f64) -> Self {
545        self.request = self.request.query("min_spread", value);
546        self
547    }
548
549    /// Maximum spread.
550    pub fn max_spread(mut self, value: f64) -> Self {
551        self.request = self.request.query("max_spread", value);
552        self
553    }
554
555    /// Minimum price.
556    pub fn min_price(mut self, value: f64) -> Self {
557        self.request = self.request.query("min_price", value);
558        self
559    }
560
561    /// Maximum price.
562    pub fn max_price(mut self, value: f64) -> Self {
563        self.request = self.request.query("max_price", value);
564        self
565    }
566
567    /// Page size (default 100, max 500).
568    pub fn page_size(mut self, page_size: u32) -> Self {
569        self.request = self.request.query("page_size", page_size);
570        self
571    }
572
573    /// Continue from a pagination cursor; `"LTE="` marks the last page.
574    pub fn next_cursor(mut self, cursor: impl Into<String>) -> Self {
575        self.request = self.request.query("next_cursor", cursor.into());
576        self
577    }
578
579    /// Execute the request.
580    pub async fn send(self) -> Result<Paginated<RewardMarket>, ClobError> {
581        self.request.send().await
582    }
583}
584
585/// Request builder for `GET /rewards/user/markets`.
586pub struct ListUserRewardMarkets {
587    request: Request<Paginated<RewardMarketEarning>>,
588}
589
590impl ListUserRewardMarkets {
591    /// Restrict to a specific day (`YYYY-MM-DD`).
592    pub fn date(mut self, date: impl Into<String>) -> Self {
593        self.request = self.request.query("date", date.into());
594        self
595    }
596
597    /// Query a maker address other than the authenticated wallet.
598    pub fn maker_address(mut self, address: impl Into<String>) -> Self {
599        self.request = self.request.query("maker_address", address.into());
600        self
601    }
602
603    /// Restrict results to sponsored reward markets (default: `false`).
604    pub fn sponsored(mut self, sponsored: bool) -> Self {
605        self.request = self.request.query("sponsored", sponsored);
606        self
607    }
608
609    /// Free-text search over market questions.
610    pub fn query_text(mut self, q: impl Into<String>) -> Self {
611        self.request = self.request.query("q", q.into());
612        self
613    }
614
615    /// Filter by tag slug.
616    pub fn tag_slug(mut self, slug: impl Into<String>) -> Self {
617        self.request = self.request.query("tag_slug", slug.into());
618        self
619    }
620
621    /// Restrict to the caller's favorited markets (default: `false`).
622    pub fn favorite_markets(mut self, value: bool) -> Self {
623        self.request = self.request.query("favorite_markets", value);
624        self
625    }
626
627    /// Restrict to markets with no competing liquidity (default: `false`).
628    pub fn no_competition(mut self, value: bool) -> Self {
629        self.request = self.request.query("no_competition", value);
630        self
631    }
632
633    /// Restrict to markets with mergeable positions (default: `false`).
634    pub fn only_mergeable(mut self, value: bool) -> Self {
635        self.request = self.request.query("only_mergeable", value);
636        self
637    }
638
639    /// Restrict to markets where the caller has open orders (default: `false`).
640    pub fn only_open_orders(mut self, value: bool) -> Self {
641        self.request = self.request.query("only_open_orders", value);
642        self
643    }
644
645    /// Restrict to markets where the caller has open positions (default: `false`).
646    pub fn only_open_positions(mut self, value: bool) -> Self {
647        self.request = self.request.query("only_open_positions", value);
648        self
649    }
650
651    /// Sort field.
652    pub fn order_by(mut self, order_by: UserRewardMarketOrderBy) -> Self {
653        self.request = self.request.query("order_by", order_by.as_str());
654        self
655    }
656
657    /// Sort direction.
658    pub fn position(mut self, position: SortPosition) -> Self {
659        self.request = self.request.query("position", position.as_str());
660        self
661    }
662
663    /// Page size (default 100, max 500).
664    pub fn page_size(mut self, page_size: u32) -> Self {
665        self.request = self.request.query("page_size", page_size);
666        self
667    }
668
669    /// Continue from a pagination cursor; `"LTE="` marks the last page.
670    pub fn next_cursor(mut self, cursor: impl Into<String>) -> Self {
671        self.request = self.request.query("next_cursor", cursor.into());
672        self
673    }
674
675    /// Execute the request.
676    pub async fn send(self) -> Result<Paginated<RewardMarketEarning>, ClobError> {
677        self.request.send().await
678    }
679}
680
681/// Rebated fees for a maker on a specific market and date
682/// (`GET /rebates/current`).
683#[derive(Debug, Clone, Serialize, Deserialize)]
684pub struct RebatedFees {
685    /// Date of the rebate (`YYYY-MM-DD`).
686    pub date: String,
687    /// Condition ID of the market.
688    pub condition_id: String,
689    /// Asset address (e.g. the USDC contract).
690    pub asset_address: String,
691    /// Maker's address.
692    pub maker_address: String,
693    /// Rebated fee amount in USDC, as a decimal string.
694    pub rebated_fees_usdc: String,
695}
696
697/// User earnings response
698#[derive(Debug, Clone, Serialize, Deserialize)]
699pub struct RewardEarnings {
700    #[serde(flatten)]
701    pub data: serde_json::Value,
702}
703
704/// User total accumulated earnings
705#[derive(Debug, Clone, Serialize, Deserialize)]
706pub struct RewardTotalEarnings {
707    #[serde(flatten)]
708    pub data: serde_json::Value,
709}
710
711/// User reward percentages
712#[derive(Debug, Clone, Serialize, Deserialize)]
713pub struct RewardPercentages {
714    #[serde(flatten)]
715    pub data: serde_json::Value,
716}
717
718/// Per-market earnings breakdown
719#[derive(Debug, Clone, Serialize, Deserialize)]
720pub struct RewardMarketEarning {
721    #[serde(flatten)]
722    pub data: serde_json::Value,
723}
724
725/// Reward market information
726#[derive(Debug, Clone, Serialize, Deserialize)]
727pub struct RewardMarket {
728    #[serde(flatten)]
729    pub data: serde_json::Value,
730}
731
732/// Pagination envelope used by the rewards list endpoints
733/// (`GET /rewards/markets/current` and `GET /rewards/user/markets`).
734///
735/// These endpoints wrap results in a pagination object rather than returning a
736/// bare array, so the items live in [`Self::data`].
737#[derive(Debug, Clone, Serialize, Deserialize)]
738pub struct Paginated<T> {
739    /// Items for this page.
740    #[serde(default = "Vec::new")]
741    pub data: Vec<T>,
742    /// Cursor for the next page; a value of `"LTE="` indicates the last page.
743    #[serde(default)]
744    pub next_cursor: Option<String>,
745    /// Page size limit reported by the server.
746    #[serde(default)]
747    pub limit: Option<u32>,
748    /// Number of items in this page.
749    #[serde(default)]
750    pub count: Option<u32>,
751}
752
753#[cfg(test)]
754mod tests {
755    use super::*;
756
757    #[test]
758    fn reward_earnings_deserializes() {
759        let json = r#"{"amount": "1.5", "day": "2024-01-15"}"#;
760        let resp: RewardEarnings = serde_json::from_str(json).unwrap();
761        assert_eq!(resp.data["amount"], "1.5");
762        assert_eq!(resp.data["day"], "2024-01-15");
763    }
764
765    #[test]
766    fn reward_total_earnings_deserializes() {
767        let json = r#"{"total": "42.0"}"#;
768        let resp: RewardTotalEarnings = serde_json::from_str(json).unwrap();
769        assert_eq!(resp.data["total"], "42.0");
770    }
771
772    #[test]
773    fn reward_total_earnings_list_deserializes() {
774        // GET /rewards/user/total returns an array of per-asset totals, not a
775        // single object. Regression test for that shape (observed live).
776        let json = r#"[{"asset_address": "0xabc", "total": "42.0"}]"#;
777        let resp: Vec<RewardTotalEarnings> = serde_json::from_str(json).unwrap();
778        assert_eq!(resp.len(), 1);
779        assert_eq!(resp[0].data["total"], "42.0");
780    }
781
782    #[test]
783    fn reward_percentages_deserializes() {
784        let json = r#"{"maker": "0.5", "taker": "0.3"}"#;
785        let resp: RewardPercentages = serde_json::from_str(json).unwrap();
786        assert_eq!(resp.data["maker"], "0.5");
787    }
788
789    #[test]
790    fn reward_market_earning_list_deserializes() {
791        let json = r#"[
792            {"condition_id": "0xabc", "amount": "10.0"},
793            {"condition_id": "0xdef", "amount": "5.0"}
794        ]"#;
795        let resp: Vec<RewardMarketEarning> = serde_json::from_str(json).unwrap();
796        assert_eq!(resp.len(), 2);
797        assert_eq!(resp[0].data["condition_id"], "0xabc");
798    }
799
800    #[test]
801    fn reward_market_deserializes() {
802        let json = r#"{"condition_id": "0xabc", "reward_rate": "0.01"}"#;
803        let resp: RewardMarket = serde_json::from_str(json).unwrap();
804        assert_eq!(resp.data["condition_id"], "0xabc");
805    }
806
807    #[test]
808    fn reward_market_list_deserializes() {
809        let json = r#"[{"condition_id": "0xabc"}, {"condition_id": "0xdef"}]"#;
810        let resp: Vec<RewardMarket> = serde_json::from_str(json).unwrap();
811        assert_eq!(resp.len(), 2);
812    }
813
814    #[test]
815    fn current_markets_paginated_response_deserializes() {
816        // GET /rewards/markets/current wraps results in a pagination envelope,
817        // not a bare array. Regression test for that shape (observed live).
818        let json = r#"{
819            "limit": 500,
820            "count": 1,
821            "next_cursor": "LTE=",
822            "data": [
823                {"condition_id": "0xabc", "rewards_max_spread": 99}
824            ]
825        }"#;
826        let page: Paginated<RewardMarket> =
827            serde_json::from_str(json).expect("paginated reward markets should deserialize");
828        assert_eq!(page.data.len(), 1);
829        assert_eq!(page.count, Some(1));
830        assert_eq!(page.next_cursor.as_deref(), Some("LTE="));
831        assert_eq!(page.data[0].data["condition_id"], "0xabc");
832    }
833
834    #[test]
835    fn market_earnings_paginated_response_deserializes() {
836        // GET /rewards/user/markets wraps results in the same pagination envelope.
837        let json = r#"{
838            "limit": 100,
839            "count": 1,
840            "next_cursor": "LTE=",
841            "data": [
842                {"condition_id": "0xabc", "earnings": 0.237519}
843            ]
844        }"#;
845        let page: Paginated<RewardMarketEarning> =
846            serde_json::from_str(json).expect("paginated market earnings should deserialize");
847        assert_eq!(page.data.len(), 1);
848        assert_eq!(page.count, Some(1));
849        assert_eq!(page.data[0].data["condition_id"], "0xabc");
850    }
851
852    #[test]
853    fn reward_earnings_empty_object_deserializes() {
854        let json = r#"{}"#;
855        let resp: RewardEarnings = serde_json::from_str(json).unwrap();
856        assert!(resp.data.is_object());
857    }
858}