Skip to main content

polyoxide_data/api/
holders.rs

1use polyoxide_core::{HttpClient, QueryBuilder, Request};
2use serde::{Deserialize, Serialize};
3
4use crate::error::DataApiError;
5
6/// Holders namespace for holder-related operations
7#[derive(Clone)]
8pub struct Holders {
9    pub(crate) http_client: HttpClient,
10}
11
12impl Holders {
13    /// Get top holders for markets
14    pub fn list(&self, markets: impl IntoIterator<Item = impl ToString>) -> ListHolders {
15        let market_ids: Vec<String> = markets.into_iter().map(|s| s.to_string()).collect();
16        let mut request = Request::new(self.http_client.clone(), "/holders");
17        if !market_ids.is_empty() {
18            request = request.query("market", market_ids.join(","));
19        }
20
21        ListHolders { request }
22    }
23}
24
25/// Request builder for getting top holders
26pub struct ListHolders {
27    request: Request<Vec<MarketHolders>, DataApiError>,
28}
29
30impl ListHolders {
31    /// Set maximum number of results per market (1-500, default: 20).
32    ///
33    /// Verified live on 2026-08-03: omitting the parameter yields 20 rows, and
34    /// `limit=500` succeeds. Values above the ceiling are **clamped, not
35    /// rejected** — `limit=5000` returns HTTP 200 with the response silently
36    /// truncated to 500 rows per token, so a caller cannot tell from the status
37    /// code that it asked for more than it got.
38    ///
39    /// This is a behavior change. Until at least 2026-07-25 the venue returned
40    /// HTTP 400 `{"error":"max holders limit of 500 exceeded"}` for `limit=501`.
41    ///
42    /// `limit=0` is a trap: the venue answers with a bare `null` body rather
43    /// than `[]`, which fails to deserialize into `Vec<MarketHolders>` and so
44    /// surfaces as an error rather than an empty list.
45    ///
46    /// The value is not range-checked here — it is passed through to the venue.
47    pub fn limit(mut self, limit: u32) -> Self {
48        self.request = self.request.query("limit", limit);
49        self
50    }
51
52    /// Set minimum balance filter (0-999999, default: 1)
53    pub fn min_balance(mut self, min_balance: u32) -> Self {
54        self.request = self.request.query("minBalance", min_balance);
55        self
56    }
57
58    /// Execute the request
59    pub async fn send(self) -> Result<Vec<MarketHolders>, DataApiError> {
60        self.request.send().await
61    }
62}
63
64/// Market holders response containing token and its holders
65#[derive(Debug, Clone, Serialize, Deserialize)]
66#[serde(rename_all(deserialize = "camelCase"))]
67pub struct MarketHolders {
68    /// Token identifier
69    pub token: String,
70    /// List of holders for this token
71    pub holders: Vec<Holder>,
72}
73
74/// Individual holder of a market token
75#[derive(Debug, Clone, Serialize, Deserialize)]
76#[serde(rename_all(deserialize = "camelCase"))]
77pub struct Holder {
78    /// Proxy wallet address
79    pub proxy_wallet: String,
80    /// User bio
81    pub bio: Option<String>,
82    /// Asset identifier (token ID)
83    pub asset: Option<String>,
84    /// User pseudonym
85    pub pseudonym: Option<String>,
86    /// Amount held
87    pub amount: f64,
88    /// Whether username is displayed publicly
89    pub display_username_public: Option<bool>,
90    /// Outcome index (0 or 1 for binary markets)
91    pub outcome_index: u32,
92    /// User display name
93    pub name: Option<String>,
94    /// User profile image URL
95    pub profile_image: Option<String>,
96    /// Optimized profile image URL
97    pub profile_image_optimized: Option<String>,
98    /// Whether the user is verified
99    #[serde(default)]
100    pub verified: Option<bool>,
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn deserialize_market_holders() {
109        let json = r#"{
110            "token": "token_abc",
111            "holders": [
112                {
113                    "proxyWallet": "0xholder1",
114                    "bio": "Top trader",
115                    "asset": "token_abc",
116                    "pseudonym": "whale1",
117                    "amount": 50000.0,
118                    "displayUsernamePublic": true,
119                    "outcomeIndex": 0,
120                    "name": "Holder One",
121                    "profileImage": "https://example.com/img.png",
122                    "profileImageOptimized": "https://example.com/img_opt.png",
123                    "verified": true
124                },
125                {
126                    "proxyWallet": "0xholder2",
127                    "bio": null,
128                    "asset": null,
129                    "pseudonym": null,
130                    "amount": 1000.0,
131                    "displayUsernamePublic": null,
132                    "outcomeIndex": 1,
133                    "name": null,
134                    "profileImage": null,
135                    "profileImageOptimized": null,
136                    "verified": false
137                }
138            ]
139        }"#;
140
141        let mh: MarketHolders = serde_json::from_str(json).unwrap();
142        assert_eq!(mh.token, "token_abc");
143        assert_eq!(mh.holders.len(), 2);
144
145        let h1 = &mh.holders[0];
146        assert_eq!(h1.proxy_wallet, "0xholder1");
147        assert_eq!(h1.bio, Some("Top trader".to_string()));
148        assert!((h1.amount - 50000.0).abs() < f64::EPSILON);
149        assert_eq!(h1.outcome_index, 0);
150        assert_eq!(h1.display_username_public, Some(true));
151        assert_eq!(h1.name, Some("Holder One".to_string()));
152        assert_eq!(h1.verified, Some(true));
153
154        let h2 = &mh.holders[1];
155        assert_eq!(h2.proxy_wallet, "0xholder2");
156        assert!(h2.bio.is_none());
157        assert!(h2.asset.is_none());
158        assert!(h2.pseudonym.is_none());
159        assert!((h2.amount - 1000.0).abs() < f64::EPSILON);
160        assert_eq!(h2.outcome_index, 1);
161        assert!(h2.name.is_none());
162        assert_eq!(h2.verified, Some(false));
163    }
164
165    #[test]
166    fn deserialize_empty_holders_list() {
167        let json = r#"{"token": "empty_token", "holders": []}"#;
168        let mh: MarketHolders = serde_json::from_str(json).unwrap();
169        assert_eq!(mh.token, "empty_token");
170        assert!(mh.holders.is_empty());
171    }
172
173    #[test]
174    fn holder_without_verified_field() {
175        let json = r#"{
176            "proxyWallet": "0xholder",
177            "amount": 100.0,
178            "outcomeIndex": 0
179        }"#;
180        let h: Holder = serde_json::from_str(json).unwrap();
181        assert_eq!(h.proxy_wallet, "0xholder");
182        assert!(h.verified.is_none());
183    }
184}