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    request::{AuthMode, Request},
7    types::SignatureType,
8};
9
10/// Rewards namespace for liquidity reward operations
11#[derive(Clone)]
12pub struct Rewards {
13    pub(crate) http_client: HttpClient,
14    pub(crate) wallet: Wallet,
15    pub(crate) credentials: Credentials,
16    pub(crate) signer: Signer,
17    pub(crate) chain_id: u64,
18    pub(crate) signature_type: SignatureType,
19}
20
21impl Rewards {
22    fn l2_auth(&self) -> AuthMode {
23        AuthMode::L2 {
24            address: self.wallet.address(),
25            credentials: self.credentials.clone(),
26            signer: self.signer.clone(),
27        }
28    }
29
30    /// Get user earnings for a specific day (`GET /rewards/user`).
31    ///
32    /// `date` must be in `YYYY-MM-DD` format (required by the API). The
33    /// `signature_type` query parameter is taken from the client configuration.
34    pub fn earnings(&self, date: impl Into<String>) -> Request<RewardEarnings> {
35        Request::get(
36            self.http_client.clone(),
37            "/rewards/user",
38            self.l2_auth(),
39            self.chain_id,
40        )
41        .query("date", date.into())
42        .query("signature_type", self.signature_type as u8)
43    }
44
45    /// Get user total earnings for a specific day (`GET /rewards/user/total`).
46    ///
47    /// `date` must be in `YYYY-MM-DD` format (required by the API). The endpoint
48    /// returns an array of totals grouped by asset address.
49    pub fn total_earnings(&self, date: impl Into<String>) -> Request<Vec<RewardTotalEarnings>> {
50        Request::get(
51            self.http_client.clone(),
52            "/rewards/user/total",
53            self.l2_auth(),
54            self.chain_id,
55        )
56        .query("date", date.into())
57        .query("signature_type", self.signature_type as u8)
58    }
59
60    /// Get user reward percentages
61    pub fn percentages(&self) -> Request<RewardPercentages> {
62        Request::get(
63            self.http_client.clone(),
64            "/rewards/user/percentages",
65            self.l2_auth(),
66            self.chain_id,
67        )
68        .query("signature_type", self.signature_type as u8)
69    }
70
71    /// Get user earnings broken down by market (`GET /rewards/user/markets`).
72    ///
73    /// Returns a paginated envelope; the per-market earnings are in `data`.
74    pub fn market_earnings(&self) -> Request<Paginated<RewardMarketEarning>> {
75        Request::get(
76            self.http_client.clone(),
77            "/rewards/user/markets",
78            self.l2_auth(),
79            self.chain_id,
80        )
81        .query("signature_type", self.signature_type as u8)
82    }
83
84    /// Get currently active reward markets (`GET /rewards/markets/current`).
85    ///
86    /// Returns a paginated envelope (`data`, `next_cursor`, `limit`, `count`);
87    /// the reward markets are in the `data` field.
88    pub fn current_markets(&self) -> Request<Paginated<RewardMarket>> {
89        Request::get(
90            self.http_client.clone(),
91            "/rewards/markets/current",
92            AuthMode::None,
93            self.chain_id,
94        )
95    }
96
97    /// Get rewards for a specific market
98    pub fn market(&self, condition_id: impl Into<String>) -> Request<RewardMarket> {
99        Request::get(
100            self.http_client.clone(),
101            format!(
102                "/rewards/markets/{}",
103                urlencoding::encode(&condition_id.into())
104            ),
105            AuthMode::None,
106            self.chain_id,
107        )
108    }
109}
110
111/// User earnings response
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct RewardEarnings {
114    #[serde(flatten)]
115    pub data: serde_json::Value,
116}
117
118/// User total accumulated earnings
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct RewardTotalEarnings {
121    #[serde(flatten)]
122    pub data: serde_json::Value,
123}
124
125/// User reward percentages
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct RewardPercentages {
128    #[serde(flatten)]
129    pub data: serde_json::Value,
130}
131
132/// Per-market earnings breakdown
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct RewardMarketEarning {
135    #[serde(flatten)]
136    pub data: serde_json::Value,
137}
138
139/// Reward market information
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct RewardMarket {
142    #[serde(flatten)]
143    pub data: serde_json::Value,
144}
145
146/// Pagination envelope used by the rewards list endpoints
147/// (`GET /rewards/markets/current` and `GET /rewards/user/markets`).
148///
149/// These endpoints wrap results in a pagination object rather than returning a
150/// bare array, so the items live in [`Self::data`].
151#[derive(Debug, Clone, Serialize, Deserialize)]
152pub struct Paginated<T> {
153    /// Items for this page.
154    #[serde(default = "Vec::new")]
155    pub data: Vec<T>,
156    /// Cursor for the next page; a value of `"LTE="` indicates the last page.
157    #[serde(default)]
158    pub next_cursor: Option<String>,
159    /// Page size limit reported by the server.
160    #[serde(default)]
161    pub limit: Option<u32>,
162    /// Number of items in this page.
163    #[serde(default)]
164    pub count: Option<u32>,
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn reward_earnings_deserializes() {
173        let json = r#"{"amount": "1.5", "day": "2024-01-15"}"#;
174        let resp: RewardEarnings = serde_json::from_str(json).unwrap();
175        assert_eq!(resp.data["amount"], "1.5");
176        assert_eq!(resp.data["day"], "2024-01-15");
177    }
178
179    #[test]
180    fn reward_total_earnings_deserializes() {
181        let json = r#"{"total": "42.0"}"#;
182        let resp: RewardTotalEarnings = serde_json::from_str(json).unwrap();
183        assert_eq!(resp.data["total"], "42.0");
184    }
185
186    #[test]
187    fn reward_total_earnings_list_deserializes() {
188        // GET /rewards/user/total returns an array of per-asset totals, not a
189        // single object. Regression test for that shape (observed live).
190        let json = r#"[{"asset_address": "0xabc", "total": "42.0"}]"#;
191        let resp: Vec<RewardTotalEarnings> = serde_json::from_str(json).unwrap();
192        assert_eq!(resp.len(), 1);
193        assert_eq!(resp[0].data["total"], "42.0");
194    }
195
196    #[test]
197    fn reward_percentages_deserializes() {
198        let json = r#"{"maker": "0.5", "taker": "0.3"}"#;
199        let resp: RewardPercentages = serde_json::from_str(json).unwrap();
200        assert_eq!(resp.data["maker"], "0.5");
201    }
202
203    #[test]
204    fn reward_market_earning_list_deserializes() {
205        let json = r#"[
206            {"condition_id": "0xabc", "amount": "10.0"},
207            {"condition_id": "0xdef", "amount": "5.0"}
208        ]"#;
209        let resp: Vec<RewardMarketEarning> = serde_json::from_str(json).unwrap();
210        assert_eq!(resp.len(), 2);
211        assert_eq!(resp[0].data["condition_id"], "0xabc");
212    }
213
214    #[test]
215    fn reward_market_deserializes() {
216        let json = r#"{"condition_id": "0xabc", "reward_rate": "0.01"}"#;
217        let resp: RewardMarket = serde_json::from_str(json).unwrap();
218        assert_eq!(resp.data["condition_id"], "0xabc");
219    }
220
221    #[test]
222    fn reward_market_list_deserializes() {
223        let json = r#"[{"condition_id": "0xabc"}, {"condition_id": "0xdef"}]"#;
224        let resp: Vec<RewardMarket> = serde_json::from_str(json).unwrap();
225        assert_eq!(resp.len(), 2);
226    }
227
228    #[test]
229    fn current_markets_paginated_response_deserializes() {
230        // GET /rewards/markets/current wraps results in a pagination envelope,
231        // not a bare array. Regression test for that shape (observed live).
232        let json = r#"{
233            "limit": 500,
234            "count": 1,
235            "next_cursor": "LTE=",
236            "data": [
237                {"condition_id": "0xabc", "rewards_max_spread": 99}
238            ]
239        }"#;
240        let page: Paginated<RewardMarket> =
241            serde_json::from_str(json).expect("paginated reward markets should deserialize");
242        assert_eq!(page.data.len(), 1);
243        assert_eq!(page.count, Some(1));
244        assert_eq!(page.next_cursor.as_deref(), Some("LTE="));
245        assert_eq!(page.data[0].data["condition_id"], "0xabc");
246    }
247
248    #[test]
249    fn market_earnings_paginated_response_deserializes() {
250        // GET /rewards/user/markets wraps results in the same pagination envelope.
251        let json = r#"{
252            "limit": 100,
253            "count": 1,
254            "next_cursor": "LTE=",
255            "data": [
256                {"condition_id": "0xabc", "earnings": 0.237519}
257            ]
258        }"#;
259        let page: Paginated<RewardMarketEarning> =
260            serde_json::from_str(json).expect("paginated market earnings should deserialize");
261        assert_eq!(page.data.len(), 1);
262        assert_eq!(page.count, Some(1));
263        assert_eq!(page.data[0].data["condition_id"], "0xabc");
264    }
265
266    #[test]
267    fn reward_earnings_empty_object_deserializes() {
268        let json = r#"{}"#;
269        let resp: RewardEarnings = serde_json::from_str(json).unwrap();
270        assert!(resp.data.is_object());
271    }
272}